diff --git a/auth0-myorganization-js-1.0.1.tgz b/auth0-myorganization-js-1.0.1.tgz
new file mode 100644
index 000000000..8f686616e
Binary files /dev/null and b/auth0-myorganization-js-1.0.1.tgz differ
diff --git a/docs-site/public/img/my-organization/member-management/invitations-tab.png b/docs-site/public/img/my-organization/member-management/invitations-tab.png
new file mode 100644
index 000000000..691aa4db3
Binary files /dev/null and b/docs-site/public/img/my-organization/member-management/invitations-tab.png differ
diff --git a/docs-site/public/img/my-organization/member-management/member-details-tab.png b/docs-site/public/img/my-organization/member-management/member-details-tab.png
new file mode 100644
index 000000000..ced369703
Binary files /dev/null and b/docs-site/public/img/my-organization/member-management/member-details-tab.png differ
diff --git a/docs-site/public/img/my-organization/member-management/member-roles-tab.png b/docs-site/public/img/my-organization/member-management/member-roles-tab.png
new file mode 100644
index 000000000..3aba02a6e
Binary files /dev/null and b/docs-site/public/img/my-organization/member-management/member-roles-tab.png differ
diff --git a/docs-site/public/img/my-organization/member-management/members-tab.png b/docs-site/public/img/my-organization/member-management/members-tab.png
new file mode 100644
index 000000000..1f5487cbb
Binary files /dev/null and b/docs-site/public/img/my-organization/member-management/members-tab.png differ
diff --git a/docs-site/src/App.tsx b/docs-site/src/App.tsx
index b55f12527..e8ae1ce35 100644
--- a/docs-site/src/App.tsx
+++ b/docs-site/src/App.tsx
@@ -4,6 +4,8 @@ import Layout from './components/Layout';
import { TechProvider } from './contexts/TechContext';
import DomainTableDocs from './pages/DomainTableDocs';
import GettingStarted from './pages/GettingStarted';
+import MemberDetailDocs from './pages/MemberDetailDocs';
+import MemberManagementDocs from './pages/MemberManagementDocs';
import MyAccountIntroduction from './pages/MyAccountIntroduction';
import MyOrganizationIntroduction from './pages/MyOrganizationIntroduction';
import OrganizationDetailsEditDocs from './pages/OrganizationDetailsEditDocs';
@@ -31,6 +33,8 @@ function AppContent() {
} />
} />
} />
+ } />
+ } />
);
diff --git a/docs-site/src/components/Layout.tsx b/docs-site/src/components/Layout.tsx
index 8fb3ca6cc..505ba55ba 100644
--- a/docs-site/src/components/Layout.tsx
+++ b/docs-site/src/components/Layout.tsx
@@ -95,6 +95,8 @@ export default function Layout({ children }: LayoutProps) {
{ name: 'SsoProviderCreate', href: '/my-organization/sso-provider-create' },
{ name: 'SsoProviderEdit', href: '/my-organization/sso-provider-edit' },
{ name: 'DomainTable', href: '/my-organization/domain-table' },
+ { name: 'OrganizationMemberManagement', href: '/my-organization/member-management' },
+ { name: 'OrganizationMemberDetail', href: '/my-organization/member-detail' },
],
},
];
diff --git a/docs-site/src/pages/MemberDetailDocs.tsx b/docs-site/src/pages/MemberDetailDocs.tsx
new file mode 100644
index 000000000..fad156754
--- /dev/null
+++ b/docs-site/src/pages/MemberDetailDocs.tsx
@@ -0,0 +1,1015 @@
+import CodeBlock from '../components/CodeBlock';
+import TabbedCodeBlock from '../components/TabbedCodeBlock';
+
+export default function MemberDetailDocs() {
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+ My Organization
+
+
+
+ BETA
+
+
+
+
OrganizationMemberDetail Component
+
+ View and manage an individual organization member — user profile, assigned roles, and
+ removal — in a two-tab layout with full lifecycle controls.
+
+
+
+
+ {/* Early Access Notice */}
+
+
+
+
+
+
+
+
Beta
+
+ This Auth0 Universal Component is in Beta. By using it, you agree to the applicable
+ Free Trial terms in Okta's Master Subscription Agreement. To learn more, read{' '}
+
+ Product Release Stages
+
+ .
+
+
+
+
+
+
+ {/* Component Preview */}
+
+ Component Preview
+
+
+
+
+
+
+
+
+ {/* Setup Requirements */}
+
+ Setup Requirements
+
+
+
+
+
+
+
+ Auth0 Configuration Required
+
+
+ Before using the OrganizationMemberDetail component, ensure your tenant is
+ configured with the proper APIs, applications, and permissions.
+
+
+ Setup guide: {' '}
+
+ My Organization Components Introduction →
+
+
+
+
+
+
+
+ {/* Installation */}
+
+ Installation
+
+
+
Option 1: NPM Package
+
Install the React package:
+
+
+
+ Note: One install covers both React (SPA) and Next.js (RWA).
+ Components are always imported from the root entry{' '}
+ @auth0/universal-components-react; only{' '}
+ Auth0ComponentProvider uses a framework-specific subpath:
+
+
+
+ React (Vite, CRA, React Router) →{' '}
+ {`import { Auth0ComponentProvider } from '@auth0/universal-components-react/spa';`}
+
+
+ Next.js (App Router or Pages Router) →{' '}
+ {`import { Auth0ComponentProvider } from '@auth0/universal-components-react/rwa';`}
+
+
+
+
+
+
+
Option 2: Shadcn CLI
+
+ If you're using Shadcn, you can add the OrganizationMemberDetail block directly to
+ your project:
+
+
+
+
+ Note: This installs the React component source code in your{' '}
+ src/components/auth0/ directory along with all UI dependencies and the
+ core package.
+
+
+
+
+
+
+ {/* Basic Usage */}
+
+ Basic Usage
+
+ Pass a userId from your route to the component. Wire onBack to
+ your router so the back button returns to the member list.
+
+ ();
+ const navigate = useNavigate();
+
+ return (
+ navigate('/members')}
+ />
+ );
+}`,
+ },
+ {
+ label: 'Next.js (RWA)',
+ code: `// app/members/[userId]/page.tsx
+'use client';
+
+import { OrganizationMemberDetail } from '@auth0/universal-components-react';
+import { useRouter, useParams } from 'next/navigation';
+
+export default function MemberDetailPage() {
+ const { userId } = useParams<{ userId: string }>();
+ const router = useRouter();
+
+ return (
+ router.push('/members')}
+ />
+ );
+}`,
+ },
+ {
+ label: 'shadcn',
+ code: `import { OrganizationMemberDetail } from '@/components/auth0/my-organization/organization-member-detail';
+import { useNavigate, useParams } from 'react-router-dom';
+
+export function MemberDetailPage() {
+ const { userId } = useParams<{ userId: string }>();
+ const navigate = useNavigate();
+
+ return (
+ navigate('/members')}
+ />
+ );
+}`,
+ },
+ ]}
+ language="tsx"
+ />
+
+
+ {/* Props */}
+
+ Props
+
+ {/* Required props */}
+
+
Required props
+
+
+
+
+
+ Prop
+
+
+ Type
+
+
+ Description
+
+
+
+
+
+
+ userId *
+
+
+ string
+
+
+ Auth0 user ID of the member to display (e.g. auth0|64abc...)
+
+
+
+
+
+
+
+ {/* Display props */}
+
+
Display props
+
+ Display props control how the component renders without affecting its behavior. Use
+ these to hide sections or enable read-only mode.
+
+
+
+
+
+
+ Prop
+
+
+ Type
+
+
+ Description
+
+
+
+
+
+
+ readOnly
+
+
+ boolean
+
+
+ Disables role management and member removal actions. Default: false
+
+
+
+
+ hideHeader
+
+
+ boolean
+
+
+ Hides the component header. Default: false
+
+
+
+
+
+
+
+ {/* Action props */}
+
+
Action props
+
+ Action props handle user interactions and define what happens when users perform member
+ operations. Use lifecycle hooks (onBefore, onAfter) to
+ integrate with your application's routing and analytics.
+
+
+
+
+
+
+ Prop
+
+
+ Type
+
+
+ Description
+
+
+
+
+
+
+ onBack
+
+
+ {'() => void'}
+
+
+ Called when the user clicks the back button in the header. Wire to your router.
+
+
+
+
+ removeFromOrgAction
+
+
+ ComponentAction<string>
+
+
+ Lifecycle hooks for member removal. Input is the userId.
+
+
+
+
+ assignRolesAction
+
+
+ ComponentAction<{'{ userId: string; roleIds: string[] }'}>
+
+
+ Lifecycle hooks for role assignment.
+
+
+
+
+ removeRolesAction
+
+
+ ComponentAction<{'{ userId: string; roleIds: string[] }'}>
+
+
+ Lifecycle hooks for role removal.
+
+
+
+
+
+
+ {/* Per-action deep dives */}
+
+ {/* onBack */}
+
+
onBack
+
+ Type: {'() => void'}
+
+
+ Fires when the user clicks the back button in the header. The component does not
+ navigate on its own — wire this callback to your router so it returns to the
+ member-list route (typically the page that rendered{' '}
+ OrganizationMemberManagement).
+
+
Common Patterns:
+
navigate('/members')}
+/>
+
+// Next.js (RWA)
+ router.push('/members')}
+/>
+
+// Pop the history stack instead of navigating to a fixed URL
+ history.back()}
+/>`}
+ language="tsx"
+ title="onBack"
+ />
+
+
+ {/* removeFromOrgAction */}
+
+
removeFromOrgAction
+
+ Type: ComponentAction<string>
+
+
+ Controls the remove-from-organization flow on the member's profile tab. Both
+ lifecycle hooks receive the userId string directly. This action
+ triggers a step-up auth challenge — make sure your Auth0Provider is
+ configured with interactiveErrorHandler="popup".
+
+
Properties:
+
+
+ disabled — hide the remove button.
+
+
+ onBefore(userId) — confirm before removing. Return false{' '}
+ to cancel.
+
+
+ onAfter(userId) — runs after the member is removed. Use this to
+ navigate away or write to an audit log.
+
+
+
Common Patterns:
+
+ confirmDialog('Remove this member from the organization?'),
+ onAfter: () => navigate('/members'),
+}}
+
+// Audit log on success
+removeFromOrgAction={{
+ onAfter: (userId) => {
+ auditLog.record({ action: 'member_removed', userId });
+ },
+}}`}
+ language="tsx"
+ title="removeFromOrgAction"
+ />
+
+
+ {/* assignRolesAction */}
+
+
assignRolesAction
+
+ Type: {' '}
+ ComponentAction<{'{ userId: string; roleIds: string[] }'}>
+
+
+ Fires after one or more roles are assigned to the member from the roles tab. Both
+ lifecycle hooks receive an object with the userId and the array of
+ roleIds being assigned.
+
+
Properties:
+
+
+ disabled — hide the assign-roles button.
+
+
+ onBefore({'{ userId, roleIds }'}) — validate the selection. Return{' '}
+ false to cancel.
+
+
+ onAfter({'{ userId, roleIds }'}) — runs after the roles are assigned.
+
+
+
Common Patterns:
+
{
+ auditLog.record({ action: 'roles_assigned', userId: memberId, roleIds });
+ },
+}}
+
+// Track analytics
+assignRolesAction={{
+ onAfter: ({ roleIds }) => {
+ analytics.track('Roles Assigned', { count: roleIds.length });
+ },
+}}`}
+ language="tsx"
+ title="assignRolesAction"
+ />
+
+
+ {/* removeRolesAction */}
+
+
removeRolesAction
+
+ Type: {' '}
+ ComponentAction<{'{ userId: string; roleIds: string[] }'}>
+
+
+ Fires after one or more roles are removed from the member's role table. Both
+ lifecycle hooks receive an object with the userId and the array of
+ roleIds being removed.
+
+
Properties:
+
+
+ disabled — hide the remove-role buttons in the role table.
+
+
+ onBefore({'{ userId, roleIds }'}) — confirm before removing. Return{' '}
+ false to cancel.
+
+
+ onAfter({'{ userId, roleIds }'}) — runs after the roles are removed.
+
+
+
Common Patterns:
+
+ confirmDialog(\`Remove \${roleIds.length} role(s) from this member?\`),
+ onAfter: ({ userId: memberId, roleIds }) => {
+ auditLog.record({ action: 'roles_removed', userId: memberId, roleIds });
+ },
+}}`}
+ language="tsx"
+ title="removeRolesAction"
+ />
+
+
+
+
+ {/* Customization props */}
+
+
Customization props
+
+ Customization props let you override default text and apply CSS variables or class names
+ to match your application's design system.
+
+
+
+
+
+
+ Prop
+
+
+ Type
+
+
+ Description
+
+
+
+
+
+
+ customMessages
+
+
+ Partial<OrganizationMemberDetailMessages>
+
+
+ Override any default UI text or translations. Default: {'{}'}
+
+
+
+
+ styling
+
+
+ ComponentStyling
+
+
+ CSS variables and class overrides. Default:{' '}
+ {'{ variables: {}, classes: {} }'}
+
+
+
+
+
+
+ {/* Per-prop deep dives */}
+
+ {/* customMessages */}
+
+
customMessages
+
+ Type: Partial<OrganizationMemberDetailMessages>
+
+
+ Customize all text and translations rendered by the component. Every field is
+ optional and falls back to the built-in default. Use this prop to localize the
+ component or to align microcopy with your product voice.
+
+
+
+ Available Messages
+
+
+
+
member.detail
+
+ back_button
+ tabs.details, tabs.roles
+
+
+
+
member.detail.user_details
+
+ title
+ name, email
+ phone_number, provider
+ created_at, last_login
+
+
+
+
member.detail.actions.remove_from_org
+
+ title, description, button
+ modal.title, modal.description
+ modal.cancel_button, modal.confirm_button
+ success
+
+
+
+
member.detail.roles
+
+ title, description
+ assign_button
+ table.name, table.description
+ table.empty_message
+ table.remove_button_label
+
+
+
+
member.detail.roles.assign_modal
+
+ title, description
+ roles_label, roles_placeholder
+ submit_button, cancel_button
+ no_roles_available
+
+
+
+
member.detail.error
+
+ fetch_failed, fetch_roles_failed
+ remove_from_org_failed
+ assign_role_failed, remove_role_failed
+
+
+
+
+
Example:
+
`}
+ language="tsx"
+ title="customMessages"
+ />
+
+
+ {/* styling */}
+
+
styling
+
+ Type: ComponentStyling
+
+
+ Customize appearance with CSS variables and class overrides. Variables are
+ theme-aware (separate light, dark, and common{' '}
+ scopes); class overrides target named slots inside the component tree so you can
+ attach Tailwind utilities or your own design-system classes without forking the
+ source.
+
+
+
+ Available Styling Options
+
+
+
+
Variables — CSS custom properties
+
+
+ common — applied to both themes
+
+
+ light — light mode only
+
+
+ dark — dark mode only
+
+
+
+
+
Classes — Component class overrides
+
+
+ OrganizationMemberDetail-root
+
+
+ OrganizationMemberDetail-header
+
+
+ OrganizationMemberDetail-tabs
+
+
+ OrganizationMemberDetail-detailsTab
+
+
+ OrganizationMemberDetail-rolesTab
+
+
+
+
+
+
Example:
+
`}
+ language="tsx"
+ title="styling"
+ />
+
+
+
+
+ {/* TypeScript Definitions */}
+
+
+
+
+
+
+
TypeScript Definitions
+
+
+
+
+
+
+
+ Complete TypeScript interface definitions for all prop types:
+
+
void;
+ readOnly?: boolean;
+ customMessages?: Partial;
+ styling?: ComponentStyling;
+ removeFromOrgAction?: ComponentAction;
+ assignRolesAction?: ComponentAction<{ userId: string; roleIds: string[] }>;
+ removeRolesAction?: ComponentAction<{ userId: string; roleIds: string[] }>;
+}
+
+// Action interface
+interface ComponentAction {
+ disabled?: boolean;
+ onBefore?: (data: T, extra?: U) => boolean | Promise;
+ onAfter?: (data: T, extra?: U) => void | Promise;
+}`}
+ language="typescript"
+ title="Complete TypeScript definitions"
+ />
+
+
+
+
+
+
+ {/* Complete Integration Example */}
+
+ Complete Integration Example
+
+ The component requires an Auth0Provider and an{' '}
+ Auth0ComponentProvider in the React tree. Set{' '}
+ interactiveErrorHandler="popup" on Auth0Provider so step-up auth
+ challenges (triggered by removing the member or mutating roles) can be resolved without
+ losing page state.
+
+ ();
+ const navigate = useNavigate();
+
+ return (
+ navigate('/members')}
+ removeFromOrgAction={{
+ onBefore: async () => confirmDialog('Remove this member from the organization?'),
+ onAfter: () => navigate('/members'),
+ }}
+ assignRolesAction={{
+ onAfter: ({ userId: memberId, roleIds }) => {
+ auditLog.record({ action: 'roles_assigned', userId: memberId, roleIds });
+ },
+ }}
+ removeRolesAction={{
+ onAfter: ({ userId: memberId, roleIds }) => {
+ auditLog.record({ action: 'roles_removed', userId: memberId, roleIds });
+ },
+ }}
+ customMessages={{
+ member: {
+ detail: {
+ back_button: 'Back to Members',
+ roles: { assign_button: 'Assign Roles' },
+ },
+ },
+ }}
+ styling={{
+ variables: {
+ light: { '--color-primary': '#4f46e5' },
+ dark: { '--color-primary': '#818cf8' },
+ },
+ }}
+ />
+ );
+}
+
+export default function App() {
+ const domain = 'YOUR_TENANT.auth0.com';
+ const clientId = 'YOUR_CLIENT_ID';
+
+ return (
+
+
+
+
+
+ );
+}`}
+ language="tsx"
+ title="Complete implementation example"
+ />
+
+
+
+
+ {/* Advanced Customization */}
+
+ Advanced Customization
+
+ The OrganizationMemberDetail component is composed of smaller subcomponents and
+ hooks. You can import them individually to build custom workflows.
+
+
+
+
Available Subcomponents
+
+ OrganizationMemberEditDetailsTab — User profile card + remove-from-org
+ danger zone
+
+ OrganizationMemberEditRolesTab — Role table with assign and remove
+ controls
+
+ OrganizationMemberAssignRolesModal — Role selector modal
+
+ OrganizationMemberRemoveRoleModal — Single-role removal confirmation
+
+ MemberRemoveFromOrgModal — Member removal confirmation modal
+
+ OrganizationMemberDetailView — Stateless view layer (bring your own data
+ via useOrganizationMemberDetail)
+
+
+
+
Available Hooks
+
+ useOrganizationMemberDetail — Full data + interaction layer: member
+ query, role queries, modal state, and all event handlers
+
+
+
+
+
+ );
+}
diff --git a/docs-site/src/pages/MemberManagementDocs.tsx b/docs-site/src/pages/MemberManagementDocs.tsx
new file mode 100644
index 000000000..e75af5689
--- /dev/null
+++ b/docs-site/src/pages/MemberManagementDocs.tsx
@@ -0,0 +1,1166 @@
+import CodeBlock from '../components/CodeBlock';
+import TabbedCodeBlock from '../components/TabbedCodeBlock';
+
+export default function MemberManagementDocs() {
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+ My Organization
+
+
+
+ BETA
+
+
+
+
+ OrganizationMemberManagement Component
+
+
+ Manage organization members and invitations in a tabbed interface — view the member
+ list, invite new members, and manage pending invitations with full lifecycle controls.
+
+
+
+
+ {/* Early Access Notice */}
+
+
+
+
+
+
+
+
Beta
+
+ This Auth0 Universal Component is in Beta. By using it, you agree to the applicable
+ Free Trial terms in Okta's Master Subscription Agreement. To learn more, read{' '}
+
+ Product Release Stages
+
+ .
+
+
+
+
+
+
+ {/* Component Preview */}
+
+ Component Preview
+
+
+
+
+
+
+
+
+ {/* Setup Requirements */}
+
+ Setup Requirements
+
+
+
+
+
+
+
+ Auth0 Configuration Required
+
+
+ Before using the OrganizationMemberManagement component, ensure your tenant
+ is configured with the proper APIs, applications, and permissions.
+
+
+ Setup guide: {' '}
+
+ My Organization Components Introduction →
+
+
+
+
+
+
+
+ {/* Installation */}
+
+ Installation
+
+
+
Option 1: NPM Package
+
Install the React package:
+
+
+
+ Note: One install covers both React (SPA) and Next.js (RWA).
+ Components are always imported from the root entry{' '}
+ @auth0/universal-components-react; only{' '}
+ Auth0ComponentProvider uses a framework-specific subpath:
+
+
+
+ React (Vite, CRA, React Router) →{' '}
+ {`import { Auth0ComponentProvider } from '@auth0/universal-components-react/spa';`}
+
+
+ Next.js (App Router or Pages Router) →{' '}
+ {`import { Auth0ComponentProvider } from '@auth0/universal-components-react/rwa';`}
+
+
+
+
+
+
+
Option 2: Shadcn CLI
+
+ If you're using Shadcn, you can add the OrganizationMemberManagement block directly to
+ your project:
+
+
+
+
+ Note: This installs the React component source code in your{' '}
+ src/components/auth0/ directory along with all UI dependencies and the
+ core package.
+
+
+
+
+
+
+ {/* Basic Usage */}
+
+ Basic Usage
+
+ The component has no required props — it loads the current organization's members and
+ pending invitations from the My Organization API automatically.
+
+ ;
+}`,
+ },
+ {
+ label: 'Next.js (RWA)',
+ code: `// app/members/page.tsx
+'use client';
+
+import { OrganizationMemberManagement } from '@auth0/universal-components-react';
+
+export default function MembersPage() {
+ return ;
+}`,
+ },
+ {
+ label: 'shadcn',
+ code: `import { OrganizationMemberManagement } from '@/components/auth0/my-organization/organization-member-management';
+
+export function MembersPage() {
+ return ;
+}`,
+ },
+ ]}
+ language="tsx"
+ />
+
+
+ {/* Props */}
+
+ Props
+
+ {/* Required props */}
+
+
Required props
+
+ OrganizationMemberManagement has no required props. It loads the current
+ organization's members and pending invitations from the My Organization API
+ automatically.
+
+
+
+ {/* Display props */}
+
+
Display props
+
+ Display props control how the component renders without affecting its behavior. Use
+ these to hide sections or enable read-only mode.
+
+
+
+
+
+
+ Prop
+
+
+ Type
+
+
+ Description
+
+
+
+
+
+
+ hideHeader
+
+
+ boolean
+
+
+ Hides the component header section. Default: false
+
+
+
+
+ readOnly
+
+
+ boolean
+
+
+ Disables all mutation actions (invite, revoke, resend). Default:{' '}
+ false
+
+
+
+
+
+
+
+ {/* Action props */}
+
+
Action props
+
+ Action props handle user interactions and define what happens when users perform member
+ and invitation operations. Use lifecycle hooks (onBefore,{' '}
+ onAfter) to integrate with your application's routing and analytics.
+
+
+
+
+
+
+ Prop
+
+
+ Type
+
+
+ Description
+
+
+
+
+
+
+ createInvitationAction
+
+
+ ComponentAction<CreateInvitationInput, MemberInvitation>
+
+
+ Lifecycle hooks for invitation creation.
+
+
+
+
+ revokeInvitationAction
+
+
+ ComponentAction<MemberInvitation>
+
+
+ Lifecycle hooks for invitation revocation.
+
+
+
+
+ resendInvitationAction
+
+
+ ComponentAction<MemberInvitation, MemberInvitation>
+
+
+ Lifecycle hooks for revoke-and-resend.
+
+
+
+
+ viewMemberDetailsAction
+
+
+ ComponentAction<string>
+
+
+ Lifecycle hooks for viewing member details. Input is the userId. Use{' '}
+ onAfter to navigate to the member detail page.
+
+
+
+
+ removeFromOrgAction
+
+
+ ComponentAction<string>
+
+
+ Lifecycle hooks for member removal. Input is the userId.
+
+
+
+
+ assignRolesAction
+
+
+ ComponentAction<{'{ userId: string; roleIds: string[] }'}>
+
+
+ Lifecycle hooks for role assignment to members.
+
+
+
+
+
+
+ {/* Per-action deep dives */}
+
+ {/* createInvitationAction */}
+
+
createInvitationAction
+
+ Type: {' '}
+ ComponentAction<CreateInvitationInput, MemberInvitation>
+
+
+ Controls the invitation-creation flow. Fires when an admin submits the "Invite
+ member" modal. Use onBefore to validate the invitee list (for example,
+ against a blocklist) and onAfter to track analytics or refetch
+ dependent data.
+
+
Properties:
+
+
+ disabled — hide the "Invite member" button entirely.
+
+
+ onBefore(input) — runs before the invitation is sent. Return{' '}
+ false to cancel. input.invitees is the array of invitees
+ being created (one per row in the modal).
+
+
+ onAfter(input, createdInvitation) — runs after the invitation is
+ successfully created. Receives both the original input and the created invitation
+ record.
+
+
+
Common Patterns:
+
{
+ analytics.track('Invitation Sent', {
+ email: input.invitees[0].email,
+ });
+ },
+}}
+
+// Validate against a blocklist before sending
+createInvitationAction={{
+ onBefore: async (input) => {
+ return !blocklist.includes(input.invitees[0].email);
+ },
+}}
+
+// Refetch the org's seat counter after the invite lands
+createInvitationAction={{
+ onAfter: () => refetchSeatUsage(),
+}}`}
+ language="tsx"
+ title="createInvitationAction"
+ />
+
+
+ {/* revokeInvitationAction */}
+
+
revokeInvitationAction
+
+ Type: ComponentAction<MemberInvitation>
+
+
+ Controls the invitation-revoke flow. Fires when an admin revokes a pending
+ invitation from the invitation list. Receives the invitation record being revoked.
+
+
Properties:
+
+
+ disabled — hide the revoke option in the invitation row menu.
+
+
+ onBefore(invitation) — confirm before revoking. Return{' '}
+ false to cancel.
+
+
+ onAfter(invitation) — runs after the invitation is revoked. Use this
+ to refresh state outside the component.
+
+
+
Common Patterns:
+
refetchSeatUsage(),
+}}
+
+// Confirm before revoke
+revokeInvitationAction={{
+ onBefore: (invitation) => confirm(
+ \`Revoke invitation for \${invitation.invitee.email}?\`
+ ),
+}}`}
+ language="tsx"
+ title="revokeInvitationAction"
+ />
+
+
+ {/* resendInvitationAction */}
+
+
resendInvitationAction
+
+ Type: {' '}
+ ComponentAction<MemberInvitation, MemberInvitation>
+
+
+ Controls the revoke-and-resend flow. The component revokes the original invitation
+ and creates a new one with the same details. onAfter receives both the
+ old and new invitations.
+
+
Properties:
+
+
+ disabled — hide the resend option in the invitation row menu.
+
+
+ onBefore(invitation) — confirm before resending. Return{' '}
+ false to cancel.
+
+
+ onAfter(originalInvitation, newInvitation) — runs after the new
+ invitation is sent.
+
+
+
Common Patterns:
+
{
+ toast.success(\`Invitation resent to \${newInvitation.invitee.email}\`);
+ },
+}}
+
+// Track analytics
+resendInvitationAction={{
+ onAfter: (original) => {
+ analytics.track('Invitation Resent', { email: original.invitee.email });
+ },
+}}`}
+ language="tsx"
+ title="resendInvitationAction"
+ />
+
+
+ {/* viewMemberDetailsAction */}
+
+
+ viewMemberDetailsAction
+
+
+ Type: ComponentAction<string>
+
+
+ Fires when an admin requests the per-member detail view from the member list.
+ Receives the userId string. The standard wiring is to navigate to the
+ route that renders OrganizationMemberDetail; the same{' '}
+ userId flows through to its required userId prop.
+
+
Properties:
+
+
+ disabled — hide the "View details" entry in the row's actions menu.
+
+
+ onAfter(userId) — runs after the user requests the detail view. Wire
+ to your router.
+
+
+
Common Patterns:
+
navigate(\`/members/\${userId}\`),
+}}
+
+// Next.js (RWA)
+viewMemberDetailsAction={{
+ onAfter: (userId) => router.push(\`/members/\${userId}\`),
+}}
+
+// Track analytics in addition to navigation
+viewMemberDetailsAction={{
+ onAfter: (userId) => {
+ analytics.track('Member Details Viewed', { userId });
+ navigate(\`/members/\${userId}\`);
+ },
+}}`}
+ language="tsx"
+ title="viewMemberDetailsAction"
+ />
+
+
+ {/* removeFromOrgAction */}
+
+
removeFromOrgAction
+
+ Type: ComponentAction<string>
+
+
+ Controls the remove-from-organization flow on a specific member row. Both lifecycle
+ hooks receive the userId string directly. This action triggers a
+ step-up auth challenge — make sure your Auth0Provider is configured
+ with interactiveErrorHandler="popup".
+
+
Properties:
+
+
+ disabled — hide the remove option in the row menu.
+
+
+ onBefore(userId) — confirm before removing. Return false{' '}
+ to cancel.
+
+
+ onAfter(userId) — runs after the member is removed. Use this to
+ refresh seat usage or write to an audit log.
+
+
+
Common Patterns:
+
+ confirmDialog(\`Remove member \${userId} from the organization?\`),
+}}
+
+// Audit log on success
+removeFromOrgAction={{
+ onAfter: (userId) => {
+ auditLog.record({ action: 'member_removed', userId });
+ },
+}}
+
+// Refresh dependent data
+removeFromOrgAction={{
+ onAfter: () => refetchMemberList(),
+}}`}
+ language="tsx"
+ title="removeFromOrgAction"
+ />
+
+
+ {/* assignRolesAction */}
+
+
assignRolesAction
+
+ Type: {' '}
+ ComponentAction<{'{ userId: string; roleIds: string[] }'}>
+
+
+ Fires after an admin assigns one or more roles to a member from the row's role
+ modal. Both lifecycle hooks receive an object with the userId and the
+ array of roleIds being assigned.
+
+
Properties:
+
+
+ disabled — hide the assign-roles option.
+
+
+ onBefore({'{ userId, roleIds }'}) — validate the selection. Return{' '}
+ false to cancel.
+
+
+ onAfter({'{ userId, roleIds }'}) — runs after the roles are assigned.
+ Use this to write to an audit log or refresh role badges.
+
+
+
Common Patterns:
+
{
+ auditLog.record({ action: 'roles_assigned', userId, roleIds });
+ },
+}}
+
+// Validate selection (e.g. forbid combining mutually-exclusive roles)
+assignRolesAction={{
+ onBefore: ({ roleIds }) => {
+ if (roleIds.includes('admin') && roleIds.includes('viewer')) {
+ toast.error('Admin and Viewer cannot be assigned together');
+ return false;
+ }
+ return true;
+ },
+}}`}
+ language="tsx"
+ title="assignRolesAction"
+ />
+
+
+
+
+ {/* Customization props */}
+
+
Customization props
+
+ Customization props let you override default text and apply CSS variables or class names
+ to match your application's design system.
+
+
+
+
+
+
+ Prop
+
+
+ Type
+
+
+ Description
+
+
+
+
+
+
+ customMessages
+
+
+ Partial<OrganizationMemberManagementMessages>
+
+
+ Override any default UI text or translations. Default: {'{}'}
+
+
+
+
+ styling
+
+
+ ComponentStyling
+
+
+ CSS variables and class overrides. Default:{' '}
+ {'{ variables: {}, classes: {} }'}
+
+
+
+
+
+
+ {/* Per-prop deep dives */}
+
+ {/* customMessages */}
+
+
customMessages
+
+ Type: {' '}
+ Partial<OrganizationMemberManagementMessages>
+
+
+ Customize all text and translations rendered by the component. Every field is
+ optional and falls back to the built-in default. Use this prop to localize the
+ component or to align microcopy with your product voice.
+
+
+
+ Available Messages
+
+
+
+
+
+
member.table
+
+ columns.name / roles / last_login
+ empty_message, search_placeholder
+ filter_by_role, all_roles
+
+
+
+
member.actions
+
+ assign_roles, remove_from_org, view_details
+
+
+
+
member.assign_roles
+
+ title, description
+ roles_label, roles_placeholder
+ submit_button, cancel_button
+
+
+
+
member.remove_from_org
+
+ title, description
+ confirm_button, cancel_button
+
+
+
+
invitation.table
+
+ columns.email / status / inviter
+ columns.created_at / expires_at / roles
+ empty_message, search_placeholder
+ filter_by_role, all_roles
+ status_pending, status_expired
+
+
+
+
invitation.create
+
+ title, description
+ email_label, email_placeholder
+ roles_label, provider_label
+ submit_button, cancel_button
+
+
+
+
invitation.details
+
+ title, email_label, status_label
+ roles_label, provider_label
+ copy_url_button, revoke_button, resend_button
+
+
+
+
invitation.error / success
+
+ error.fetch_failed, create_failed, revoke_failed
+ success.url_copied, invitation_resent
+
+
+
+
+
Example:
+
`}
+ language="tsx"
+ title="customMessages"
+ />
+
+
+ {/* styling */}
+
+
styling
+
+ Type: ComponentStyling
+
+
+ Customize appearance with CSS variables and class overrides. Variables are
+ theme-aware (separate light, dark, and common{' '}
+ scopes); class overrides target named slots inside the component tree so you can
+ attach Tailwind utilities or your own design-system classes without forking the
+ source.
+
+
+
+ Available Styling Options
+
+
+
+
Variables — CSS custom properties
+
+
+ common — applied to both themes
+
+
+ light — light mode only
+
+
+ dark — dark mode only
+
+
+
+
+
Classes — Component class overrides
+
+
+ OrganizationMemberManagement-root
+
+
+ OrganizationMemberManagement-header
+
+
+ OrganizationMemberManagement-tabs
+
+
+ OrganizationMemberTab-table
+
+
+ OrganizationInvitationTab-table
+
+
+ OrganizationInvitationTab-createModal
+
+
+ OrganizationInvitationTab-detailsModal
+
+
+ OrganizationInvitationTab-revokeModal
+
+
+ OrganizationInvitationTab-revokeResendModal
+
+
+
+
+
+
Example:
+
`}
+ language="tsx"
+ title="styling"
+ />
+
+
+
+
+ {/* TypeScript Definitions */}
+
+
+
+
+
+
+
TypeScript Definitions
+
+
+
+
+
+
+
+ Complete TypeScript interface definitions for all prop types:
+
+
;
+ styling?: ComponentStyling;
+ createInvitationAction?: ComponentAction;
+ revokeInvitationAction?: ComponentAction;
+ resendInvitationAction?: ComponentAction;
+ viewMemberDetailsAction?: ComponentAction;
+ assignRolesAction?: ComponentAction<{ userId: string; roleIds: string[] }>;
+ removeFromOrgAction?: ComponentAction;
+}
+
+// Action interface
+interface ComponentAction {
+ disabled?: boolean;
+ onBefore?: (data: T, extra?: U) => boolean | Promise;
+ onAfter?: (data: T, extra?: U) => void | Promise;
+}`}
+ language="typescript"
+ title="Complete TypeScript definitions"
+ />
+
+
+
+
+
+
+ {/* Complete Integration Example */}
+
+ Complete Integration Example
+
+ The component requires an Auth0Provider and an{' '}
+ Auth0ComponentProvider in the React tree. Set{' '}
+ interactiveErrorHandler="popup" on Auth0Provider so that step-up
+ auth challenges (triggered by sensitive mutations such as removing a member or assigning
+ roles) can be resolved without losing page state.
+
+ !blocklist.includes(input.invitees[0].email),
+ onAfter: (input) => {
+ analytics.track('Invitation Sent', { email: input.invitees[0].email });
+ },
+ }}
+ revokeInvitationAction={{
+ onAfter: () => refetchMemberCount(),
+ }}
+ viewMemberDetailsAction={{
+ onAfter: (userId) => navigate(\`/members/\${userId}\`),
+ }}
+ removeFromOrgAction={{
+ onBefore: async (userId) =>
+ confirmDialog(\`Remove member \${userId} from the organization?\`),
+ onAfter: (userId) => {
+ auditLog.record({ action: 'member_removed', userId });
+ },
+ }}
+ assignRolesAction={{
+ onAfter: ({ userId, roleIds }) => {
+ auditLog.record({ action: 'roles_assigned', userId, roleIds });
+ },
+ }}
+ customMessages={{
+ header: { title: 'Team Members' },
+ tabs: { invitations: 'Pending Invites' },
+ }}
+ styling={{
+ variables: {
+ light: { '--color-primary': '#4f46e5' },
+ dark: { '--color-primary': '#818cf8' },
+ },
+ }}
+ />
+ );
+}
+
+export default function App() {
+ const domain = 'YOUR_TENANT.auth0.com';
+ const clientId = 'YOUR_CLIENT_ID';
+
+ return (
+
+
+
+
+
+ );
+}`}
+ language="tsx"
+ title="Complete implementation example"
+ />
+
+
+
+
+ {/* Advanced Customization */}
+
+ Advanced Customization
+
+ The OrganizationMemberManagement component is composed of smaller subcomponents and
+ hooks. You can import them individually to build custom workflows.
+
+
+
+
Available Subcomponents
+
+ Member Tab:
+
+ OrganizationMemberTable — Member list with sorting, filtering, role
+ badges, and actions menu
+
+ OrganizationMemberAssignRolesModal — Modal for assigning roles to members
+
+ OrganizationMemberRemoveFromOrgModal — Confirmation modal for member
+ removal
+
+
+ Invitation Tab:
+
+ OrganizationInvitationTable — Invitation list with sorting, filtering,
+ and pagination
+
+ OrganizationInvitationCreateModal — Modal for sending new invitations
+
+ OrganizationInvitationDetailsModal — Drawer showing full invitation
+ details with copy URL / resend / revoke
+
+ OrganizationInvitationRevokeModal — Confirmation modal for revoke and
+ revoke-and-resend
+
+
+ View Layer:
+
+ OrganizationMemberManagementView — Stateless view layer (bring your own
+ data via useOrganizationMemberManagement)
+
+
+
+
Available Hooks
+
+ useOrganizationMemberManagement — Full data + interaction layer: tab
+ state, invitation queries, modal state, and all event handlers
+
+
+
+
+
+ );
+}
diff --git a/examples/next-rwa/src/app/member-management/[user_id]/page.tsx b/examples/next-rwa/src/app/member-management/[user_id]/page.tsx
new file mode 100644
index 000000000..e5cda6897
--- /dev/null
+++ b/examples/next-rwa/src/app/member-management/[user_id]/page.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import { OrganizationMemberDetail } from '@auth0/universal-components-react';
+import { useRouter, useParams } from 'next/navigation';
+
+export default function MemberDetailPage() {
+ const router = useRouter();
+ const params = useParams();
+ const user_id = decodeURIComponent(params.user_id as string);
+
+ return (
+
+ router.push('/member-management')} />
+
+ );
+}
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..300418cf5
--- /dev/null
+++ b/examples/next-rwa/src/app/member-management/page.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+// import { OrganizationMemberManagement } from '@auth0/universal-components-react';
+// import { useRouter } from 'next/navigation';
+
+export default function MemberManagementPage() {
+ // const router = useRouter();
+ return (
+
+
+ Follow{' '}
+
+ Quickstart guidance
+ {' '}
+ on how to add Member Management component.
+
+ {/*
{
+ router.push(`/member-management/${userId}`);
+ },
+ }}
+ /> */}
+
+ );
+}
diff --git a/examples/next-rwa/src/components/navigation/side-bar.tsx b/examples/next-rwa/src/components/navigation/side-bar.tsx
index e9a45a6f1..a2d781018 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.member-management')}
+
+
diff --git a/examples/next-rwa/src/providers/i18n-provider.tsx b/examples/next-rwa/src/providers/i18n-provider.tsx
index 793c0e42d..5ef17827b 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.member-management': 'Members',
},
},
},
diff --git a/examples/react-spa-npm/src/App.tsx b/examples/react-spa-npm/src/App.tsx
index 08ec086e1..f34e51e17 100644
--- a/examples/react-spa-npm/src/App.tsx
+++ b/examples/react-spa-npm/src/App.tsx
@@ -8,6 +8,8 @@ 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 MemberDetailPage from './views/member-detail-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 +103,22 @@ 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/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-detail-page.tsx b/examples/react-spa-npm/src/views/member-detail-page.tsx
new file mode 100644
index 000000000..666a0c650
--- /dev/null
+++ b/examples/react-spa-npm/src/views/member-detail-page.tsx
@@ -0,0 +1,23 @@
+// import { OrganizationMemberDetail } from '@auth0/universal-components-react';
+// import { useNavigate, useParams } from 'react-router-dom';
+
+const MemberDetailPage = () => {
+ return (
+
+
+ Follow{' '}
+
+ Quickstart guidance
+ {' '}
+ on how to add Member Detail component.
+
+ {/*
navigate('/member-management')} /> */}
+
+ );
+};
+
+export default MemberDetailPage;
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..f22560522
--- /dev/null
+++ b/examples/react-spa-npm/src/views/member-management-page.tsx
@@ -0,0 +1,30 @@
+// import { OrganizationMemberManagement } from '@auth0/universal-components-react';
+// import { useNavigate } from 'react-router-dom';
+
+const MemberManagementPage = () => {
+ // const navigate = useNavigate();
+
+ return (
+
+
+ Follow{' '}
+
+ Quickstart guidance
+ {' '}
+ on how to add Member Management component.
+
+ {/*
{
+ navigate(`/member-management/${userId}`);
+ },
+ }}
+ /> */}
+
+ );
+};
+
+export default MemberManagementPage;
diff --git a/examples/react-spa-shadcn/src/App.tsx b/examples/react-spa-shadcn/src/App.tsx
index c39fdede8..12d9a3911 100644
--- a/examples/react-spa-shadcn/src/App.tsx
+++ b/examples/react-spa-shadcn/src/App.tsx
@@ -10,6 +10,8 @@ import { Sidebar } from './components/side-bar';
import { config } from './config/env';
// import { useDarkMode } from './hooks/use-dark-mode';
import DomainManagement from './pages/DomainManagement';
+import MemberDetail from './pages/MemberDetail';
+import MemberManagement from './pages/MemberManagement';
import IdentityProviderManagement from './pages/IdentityProviderManagement';
import IdentityProviderManagementCreate from './pages/IdentityProviderManagementCreate';
import IdentityProviderManagementEdit from './pages/IdentityProviderManagementEdit';
@@ -136,6 +138,22 @@ 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/locales/en.json b/examples/react-spa-shadcn/src/locales/en.json
index c3c5bb9fc..8b2e2d917 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"
@@ -39,5 +40,11 @@
},
"domain-management": {
"title": "Domain Management"
+ },
+ "member-management": {
+ "title": "Member Management"
+ },
+ "member-detail": {
+ "title": "Member Detail"
}
}
diff --git a/examples/react-spa-shadcn/src/locales/ja.json b/examples/react-spa-shadcn/src/locales/ja.json
index 39a4140d4..d762aff1b 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": "多要素認証"
@@ -39,5 +40,11 @@
},
"domain-management": {
"title": "ドメイン管理"
+ },
+ "member-management": {
+ "title": "メンバー管理"
+ },
+ "member-detail": {
+ "title": "メンバー詳細"
}
}
diff --git a/examples/react-spa-shadcn/src/pages/MemberDetail.tsx b/examples/react-spa-shadcn/src/pages/MemberDetail.tsx
new file mode 100644
index 000000000..e44d46d55
--- /dev/null
+++ b/examples/react-spa-shadcn/src/pages/MemberDetail.tsx
@@ -0,0 +1,34 @@
+import { useTranslation } from 'react-i18next';
+// import { useNavigate, useParams } from 'react-router-dom';
+
+// import { OrganizationMemberDetail } from '@/components/auth0/my-organization/organization-member-detail';
+
+const MemberDetail = () => {
+ const { t } = useTranslation();
+ // const navigate = useNavigate();
+ // const { user_id } = useParams<{ user_id: string }>();
+
+ return (
+
+
+ {t('member-detail.title')}
+
+
+ Follow{' '}
+
+ Quickstart guidance
+ {' '}
+ on how to add Member Detail component.
+
+
+ {/* navigate('/member-management')} /> */}
+
+
+ );
+};
+
+export default MemberDetail;
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..b525ebe16
--- /dev/null
+++ b/examples/react-spa-shadcn/src/pages/MemberManagement.tsx
@@ -0,0 +1,39 @@
+import { useTranslation } from 'react-i18next';
+// import { useNavigate } from 'react-router-dom';
+
+// import { OrganizationMemberManagement } from '@/components/auth0/my-organization/organization-member-management';
+
+const MemberManagement = () => {
+ const { t } = useTranslation();
+ // const navigate = useNavigate();
+
+ // const viewMemberDetailsAction = {
+ // onAfter: (userId: string) => {
+ // navigate(`/member-management/${userId}`);
+ // },
+ // };
+
+ return (
+
+
+ {t('member-management.title')}
+
+
+ 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..cb2adba70 100644
--- a/examples/scripts/utils/env-writer.mjs
+++ b/examples/scripts/utils/env-writer.mjs
@@ -72,6 +72,14 @@ 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",
+ "create:my_org:member_invitations",
+ "read:my_org:member_roles",
+ "delete:my_org:member_roles",
+ "create:my_org:member_roles",
+ "read:my_org:members",
+ "delete:my_org:memberships",
]
// My Account API scopes
diff --git a/examples/scripts/utils/resource-servers.mjs b/examples/scripts/utils/resource-servers.mjs
index d7eeb6b91..f0cde7475 100644
--- a/examples/scripts/utils/resource-servers.mjs
+++ b/examples/scripts/utils/resource-servers.mjs
@@ -26,6 +26,14 @@ 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",
+"create:my_org:member_invitations",
+"read:my_org:member_roles",
+"delete:my_org:member_roles",
+"create:my_org:member_roles",
+"read:my_org:members",
+"delete:my_org:memberships",
]
// My Account API Scopes - desired scopes for MFA management
diff --git a/packages/core/package.json b/packages/core/package.json
index 16b114c4d..37ab4d118 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.0.0",
+ "@auth0/myorganization-js": "file:../../auth0-myorganization-js-1.0.1.tgz",
"zod": "^3.22.4"
}
}
diff --git a/packages/core/src/i18n/custom-messages/my-organization/index.ts b/packages/core/src/i18n/custom-messages/my-organization/index.ts
index 845ecef45..1c6aa7c35 100644
--- a/packages/core/src/i18n/custom-messages/my-organization/index.ts
+++ b/packages/core/src/i18n/custom-messages/my-organization/index.ts
@@ -9,3 +9,4 @@ export * from './organization-management';
export * from './domain-management';
export * from './member-management/invitation-tab-types';
export * from './member-management/member-management-types';
+export * from './member-management/member-tab-types';
diff --git a/packages/core/src/i18n/custom-messages/my-organization/member-management/member-management-types.ts b/packages/core/src/i18n/custom-messages/my-organization/member-management/member-management-types.ts
index 60bfc9796..96174de49 100644
--- a/packages/core/src/i18n/custom-messages/my-organization/member-management/member-management-types.ts
+++ b/packages/core/src/i18n/custom-messages/my-organization/member-management/member-management-types.ts
@@ -5,6 +5,7 @@
*/
import type { OrganizationInvitationTabMessages } from './invitation-tab-types';
+import type { OrganizationMemberTabMessages } from './member-tab-types';
export interface OrganizationMemberManagementMessages {
header?: {
@@ -16,4 +17,77 @@ export interface OrganizationMemberManagementMessages {
invitations?: string;
};
invitation?: OrganizationInvitationTabMessages;
+ member?: OrganizationMemberTabMessages;
+}
+
+export interface OrganizationMemberDetailMessages {
+ member?: {
+ detail?: {
+ back_button?: string;
+ tabs?: {
+ details?: string;
+ roles?: string;
+ };
+ user_details?: {
+ title?: string;
+ name?: string;
+ email?: string;
+ phone_number?: string;
+ provider?: string;
+ created_at?: string;
+ last_login?: string;
+ };
+ actions?: {
+ remove_from_org?: {
+ title?: string;
+ description?: string;
+ button?: string;
+ modal?: {
+ title?: string;
+ description?: string;
+ cancel_button?: string;
+ confirm_button?: string;
+ };
+ success?: string;
+ };
+ };
+ roles?: {
+ title?: string;
+ description?: string;
+ assign_button?: string;
+ roles_selected?: string;
+ roles_selected_plural?: string;
+ table?: {
+ name?: string;
+ description?: string;
+ empty_message?: string;
+ remove_button_label?: string;
+ };
+ assign_modal?: {
+ title?: string;
+ description?: string;
+ roles_label?: string;
+ roles_placeholder?: string;
+ submit_button?: string;
+ cancel_button?: string;
+ no_roles_available?: string;
+ };
+ remove_confirm?: {
+ title?: string;
+ title_plural?: string;
+ description?: string;
+ description_plural?: string;
+ confirm_button?: string;
+ cancel_button?: string;
+ };
+ };
+ error?: {
+ fetch_failed?: string;
+ fetch_roles_failed?: string;
+ remove_from_org_failed?: string;
+ assign_role_failed?: string;
+ remove_role_failed?: string;
+ };
+ };
+ };
}
diff --git a/packages/core/src/i18n/custom-messages/my-organization/member-management/member-tab-types.ts b/packages/core/src/i18n/custom-messages/my-organization/member-management/member-tab-types.ts
new file mode 100644
index 000000000..3aca07074
--- /dev/null
+++ b/packages/core/src/i18n/custom-messages/my-organization/member-management/member-tab-types.ts
@@ -0,0 +1,51 @@
+/**
+ * Custom message type definitions for member tab.
+ * @module member-tab-types
+ * @internal
+ */
+
+export interface OrganizationMemberTabMessages {
+ table?: {
+ columns?: {
+ name?: string;
+ roles?: string;
+ last_login?: string;
+ };
+ empty_message?: string;
+ search_placeholder?: string;
+ filter_by_role?: string;
+ all_roles?: string;
+ reset_filter?: string;
+ showing_results?: string;
+ };
+ actions?: {
+ menu_label?: string;
+ view_details?: string;
+ assign_roles?: string;
+ remove_from_org?: string;
+ };
+ assign_roles?: {
+ title?: string;
+ description?: string;
+ roles_label?: string;
+ roles_placeholder?: string;
+ submit_button?: string;
+ cancel_button?: string;
+ no_roles_available?: string;
+ };
+ remove_from_org?: {
+ title?: string;
+ description?: string;
+ confirm_button?: string;
+ cancel_button?: string;
+ };
+ success?: {
+ removed_from_org?: string;
+ role_assigned?: string;
+ };
+ error?: {
+ fetch_failed?: string;
+ assign_roles_failed?: string;
+ remove_from_org_failed?: string;
+ };
+}
diff --git a/packages/core/src/i18n/translations/en-US.json b/packages/core/src/i18n/translations/en-US.json
index 62d83370a..345a72d85 100644
--- a/packages/core/src/i18n/translations/en-US.json
+++ b/packages/core/src/i18n/translations/en-US.json
@@ -2,6 +2,11 @@
"common": {
"copy": "Copy",
"copied": "Copied!",
+ "copy_failed": "Failed to copy",
+ "data_table": {
+ "select_all": "Select all rows",
+ "select_row": "Select row"
+ },
"error": {
"generic": "There was an issue processing your request. Please try again or contact support if the issue persists.",
"bad_request": "The request is invalid. Please check your input and try again.",
@@ -502,8 +507,8 @@
"title": "Advanced Settings",
"sign_request": {
"label": "Sign Request",
- "helper_text_metadata_file": "When enabled, the SAML authentication request will be signed. Download the certificate and provide it to the SAMLP that will receive the signed assertion to validate the signature. This needs to be enabled first in the SAMLP. Once enabled, share the metadata URL for integration.",
- "helper_text_metadata_url": "When enabled, the SAML authentication request will be signed. Download the certificate and provide it to the SAMLP that will receive the signed assertion to validate the signature."
+ "helper_text_metadata_file": "When enabled, the SAML authentication request will be signed. Download the certificate and provide it to the SAMLP that will receive the signed assertion to validate the signature. This needs to be enabled first in the SAMLP. Once enabled, share the metadata URL for integration.",
+ "helper_text_metadata_url": "When enabled, the SAML authentication request will be signed. Download the certificate and provide it to the SAMLP that will receive the signed assertion to validate the signature."
},
"request_protocol_binding": {
"label": "Request Protocol Binding",
@@ -580,7 +585,7 @@
"title": "Advanced Settings",
"sign_request": {
"label": "Sign Request",
- "helper_text": "When enabled, the SAML authentication request will be signed. Download the certificate and provide it to the SAMLP that will receive the signed assertion to validate the signature. This needs to be enabled first in the SAMLP. Once enabled, share the metadata URL for integration.",
+ "helper_text": "When enabled, the SAML authentication request will be signed. Download the certificate and provide it to the SAMLP that will receive the signed assertion to validate the signature. This needs to be enabled first in the SAMLP. Once enabled, share the metadata URL for integration.",
"error": "Please select an option"
},
"sign_request_algorithm": {
@@ -1086,20 +1091,114 @@
"columns": {
"name": "Name",
"email": "Email",
- "roles": "Roles"
+ "roles": "Roles",
+ "last_login": "Last Login"
},
- "empty_message": "No members found."
+ "empty_message": "This organization does not have any members.",
+ "search_placeholder": "Search for a member by name or email",
+ "filter_by_role": "Filter By Role",
+ "all_roles": "All",
+ "reset_filter": "Reset",
+ "never": "Never",
+ "just_now": "Just now",
+ "ago": "ago",
+ "minute": "minute",
+ "minutes": "minutes",
+ "hour": "hour",
+ "hours": "hours",
+ "day": "day",
+ "days": "days",
+ "week": "week",
+ "weeks": "weeks",
+ "month": "month",
+ "months": "months",
+ "year": "year",
+ "years": "years"
},
- "remove": {
- "title": "Remove Member",
- "description": "Are you sure you want to remove ${name} from this organization?",
- "confirm_button": "Remove",
- "cancel_button": "Cancel",
- "success": "${name} has been removed from the organization."
+ "actions": {
+ "menu_label": "Actions",
+ "view_details": "View Details",
+ "assign_role": "Assign Role",
+ "remove_from_org": "Remove from Organization"
},
"error": {
"fetch_failed": "Failed to load members. Please try again.",
- "remove_failed": "Failed to remove member. Please try again."
+ "remove_failed": "Failed to remove member. Please try again.",
+ "assign_role_failed": "Failed to assign role. Please try again."
+ },
+ "detail": {
+ "back_button": "Back to Members",
+ "user_id_label": "User ID:",
+ "tabs": {
+ "details": "Details",
+ "roles": "Roles"
+ },
+ "user_details": {
+ "title": "User Details",
+ "name": "Name",
+ "email": "Email",
+ "phone_number": "Phone Number",
+ "provider": "Provider",
+ "created_at": "Created At",
+ "last_login": "Last Login"
+ },
+ "actions": {
+ "remove_from_org": {
+ "title": "Remove Member from Organization",
+ "description": "Once confirmed, this operation cannot be undone.",
+ "button": "Delete",
+ "success": "${memberName} has been removed from the ${orgName}.",
+ "modal": {
+ "title": "Remove Member from ${orgName}",
+ "description": "Are you sure you want to remove \"${memberName}\" from this organization? This action will not delete their account but it will revoke their access and prevent them from signing in.",
+ "cancel_button": "Cancel",
+ "confirm_button": "Remove"
+ }
+ }
+ },
+ "roles": {
+ "title": "Roles",
+ "description": "Manage user roles and permissions when accessing ${orgName}.",
+ "assign_button": "Assign Roles",
+ "no_roles_available": "There are no more roles available to assign to this member.",
+ "max_roles_reached": "You have reached the maximum of ${max} roles for this member.",
+ "remove_button": "Remove Role",
+ "remove_button_plural": "Remove Roles",
+ "roles_selected": "${count} role selected",
+ "roles_selected_plural": "${count} roles selected",
+ "table": {
+ "name": "Name",
+ "description": "Description",
+ "empty_message": "No roles assigned.",
+ "remove_button_label": "Remove role ${roleName}"
+ },
+ "assign_modal": {
+ "title": "Assign Roles",
+ "roles_label": "Roles",
+ "roles_placeholder": "Select role(s)",
+ "submit_button": "Assign Role(s)",
+ "cancel_button": "Cancel",
+ "no_roles_available": "All available roles are already assigned.",
+ "success": "The role has been assigned.",
+ "success_plural": "The roles have been assigned."
+ },
+ "remove_confirm": {
+ "title": "Remove Role from Member?",
+ "title_plural": "Remove Roles from Member?",
+ "description": "This will remove the ${roleName} role from \"${memberName}\" .",
+ "confirm_button": "Remove",
+ "cancel_button": "Cancel",
+ "success": "\"${roleName}\" role has been removed from member.",
+ "success_plural": "\"${roleNames}\" roles have been removed from member."
+ }
+ },
+ "error": {
+ "fetch_failed": "Failed to load member details. Please try again.",
+ "fetch_roles_failed": "Failed to load member roles. Please try again.",
+ "remove_from_org_failed": "Failed to remove member from organization. Please try again.",
+ "assign_role_failed": "Failed to assign role. Please try again.",
+ "remove_role_failed": "Failed to remove role. Please try again."
+ }
}
},
"invitation": {
@@ -1125,6 +1224,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 +1246,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 +1279,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/fr.json b/packages/core/src/i18n/translations/fr.json
index 7c67af69f..9e3e3b1ae 100644
--- a/packages/core/src/i18n/translations/fr.json
+++ b/packages/core/src/i18n/translations/fr.json
@@ -2,6 +2,11 @@
"common": {
"copy": "Copier",
"copied": "Copié!",
+ "copy_failed": "Échec de la copie",
+ "data_table": {
+ "select_all": "Sélectionner toutes les lignes",
+ "select_row": "Sélectionner la ligne"
+ },
"error": {
"generic": "Un problème est survenu lors du traitement de votre demande. Veuillez réessayer ou contacter le support si le problème persiste.",
"bad_request": "La demande est invalide. Veuillez vérifier votre saisie et réessayer.",
@@ -1016,5 +1021,122 @@
"install_guardian_description": "Pour continuer, installez l'application Auth0 Guardian via l'app store depuis votre appareil mobile"
}
}
+ },
+ "member_management": {
+ "member": {
+ "table": {
+ "columns": {
+ "name": "Nom",
+ "email": "E-mail",
+ "roles": "Rôles",
+ "last_login": "Dernière connexion"
+ },
+ "empty_message": "Cette organisation n'a aucun membre.",
+ "search_placeholder": "Rechercher un membre par nom ou e-mail",
+ "filter_by_role": "Filtrer par rôle",
+ "all_roles": "Tous",
+ "reset_filter": "Réinitialiser",
+ "never": "Jamais",
+ "just_now": "À l'instant",
+ "ago": "il y a",
+ "minute": "minute",
+ "minutes": "minutes",
+ "hour": "heure",
+ "hours": "heures",
+ "day": "jour",
+ "days": "jours",
+ "week": "semaine",
+ "weeks": "semaines",
+ "month": "mois",
+ "months": "mois",
+ "year": "an",
+ "years": "ans"
+ },
+ "actions": {
+ "menu_label": "Actions",
+ "view_details": "Afficher les détails",
+ "assign_role": "Attribuer un rôle",
+ "remove_from_org": "Supprimer de l'organisation"
+ },
+ "error": {
+ "fetch_failed": "Échec du chargement des membres. Veuillez réessayer.",
+ "remove_failed": "Échec de la suppression du membre. Veuillez réessayer.",
+ "assign_role_failed": "Échec de l'attribution du rôle. Veuillez réessayer."
+ },
+ "detail": {
+ "back_button": "Retour aux membres",
+ "user_id_label": "ID utilisateur:",
+ "tabs": {
+ "details": "Détails",
+ "roles": "Rôles"
+ },
+ "user_details": {
+ "title": "Détails de l'utilisateur",
+ "name": "Nom",
+ "email": "E-mail",
+ "phone_number": "Numéro de téléphone",
+ "provider": "Fournisseur",
+ "created_at": "Créé le",
+ "last_login": "Dernière connexion"
+ },
+ "actions": {
+ "remove_from_org": {
+ "title": "Retirer un membre de l'organisation",
+ "description": "Une fois confirmée, cette opération ne peut pas être annulée.",
+ "button": "Supprimer",
+ "success": "${memberName} a été retiré de ${orgName}.",
+ "modal": {
+ "title": "Retirer un membre de ${orgName}",
+ "description": "Êtes-vous sûr de vouloir retirer \"${memberName}\" de cette organisation ? Cette action ne supprimera pas son compte, mais révoquera son accès et l'empêchera de se connecter.",
+ "cancel_button": "Annuler",
+ "confirm_button": "Retirer"
+ }
+ }
+ },
+ "roles": {
+ "title": "Rôles",
+ "description": "Gérez les rôles et autorisations de l'utilisateur pour l'accès à ${orgName}.",
+ "assign_button": "Attribuer des rôles",
+ "no_roles_available": "Il n'y a plus de rôles disponibles à attribuer à ce membre.",
+ "max_roles_reached": "Vous avez atteint le nombre maximum de ${max} rôles pour ce membre.",
+ "remove_button": "Supprimer le rôle",
+ "remove_button_plural": "Supprimer les rôles",
+ "roles_selected": "${count} rôle sélectionné",
+ "roles_selected_plural": "${count} rôles sélectionnés",
+ "table": {
+ "name": "Nom",
+ "description": "Description",
+ "empty_message": "Aucun rôle attribué.",
+ "remove_button_label": "Supprimer le rôle ${roleName}"
+ },
+ "assign_modal": {
+ "title": "Attribuer des rôles",
+ "roles_label": "Rôles",
+ "roles_placeholder": "Sélectionner un ou des rôles",
+ "submit_button": "Attribuer le(s) rôle(s)",
+ "cancel_button": "Annuler",
+ "no_roles_available": "Tous les rôles disponibles sont déjà attribués.",
+ "success": "Le rôle a été attribué.",
+ "success_plural": "Les rôles ont été attribués."
+ },
+ "remove_confirm": {
+ "title": "Retirer le rôle du membre ?",
+ "title_plural": "Retirer les rôles du membre ?",
+ "description": "Cela retirera le rôle ${roleName} de \"${memberName}\" .",
+ "confirm_button": "Retirer",
+ "cancel_button": "Annuler",
+ "success": "Le rôle \"${roleName}\" a été retiré du membre.",
+ "success_plural": "Les rôles \"${roleNames}\" ont été retirés du membre."
+ }
+ },
+ "error": {
+ "fetch_failed": "Échec du chargement des détails du membre. Veuillez réessayer.",
+ "fetch_roles_failed": "Échec du chargement des rôles du membre. Veuillez réessayer.",
+ "remove_from_org_failed": "Échec du retrait du membre de l'organisation. Veuillez réessayer.",
+ "assign_role_failed": "Échec de l'attribution du rôle. Veuillez réessayer.",
+ "remove_role_failed": "Échec du retrait du rôle. Veuillez réessayer."
+ }
+ }
+ }
}
}
diff --git a/packages/core/src/i18n/translations/ja.json b/packages/core/src/i18n/translations/ja.json
index a025fecb3..a8585cb5c 100644
--- a/packages/core/src/i18n/translations/ja.json
+++ b/packages/core/src/i18n/translations/ja.json
@@ -2,6 +2,11 @@
"common": {
"copy": "コピー",
"copied": "コピーしました",
+ "copy_failed": "コピーに失敗しました",
+ "data_table": {
+ "select_all": "すべての行を選択",
+ "select_row": "行を選択"
+ },
"error": {
"generic": "リクエストの処理中に問題が発生しました。もう一度お試しいただくか、問題が解決しない場合はサポートにお問い合わせください。",
"bad_request": "リクエストが無効です。入力内容を確認してもう一度お試しください。",
@@ -883,7 +888,6 @@
"modal": {
"title": "${organizationName}から${providerName}を削除",
"description": "この操作により、この組織のすべてのユーザーの${providerName} へのアクセスが取り消されます。ユーザーは削除されませんが、このプロバイダーを使用してサインインできなくなります",
-
"content": {
"description": "確認のためプロバイダー名を入力してください。",
"field": {
@@ -1088,20 +1092,114 @@
"columns": {
"name": "名前",
"email": "メール",
- "roles": "ロール"
+ "roles": "ロール",
+ "last_login": "最終ログイン"
},
- "empty_message": "メンバーが見つかりません。"
+ "empty_message": "この組織にはメンバーがいません。",
+ "search_placeholder": "名前またはメールでメンバーを検索",
+ "filter_by_role": "ロールでフィルター",
+ "all_roles": "すべて",
+ "reset_filter": "リセット",
+ "never": "一度もない",
+ "just_now": "たった今",
+ "ago": "前",
+ "minute": "分",
+ "minutes": "分",
+ "hour": "時間",
+ "hours": "時間",
+ "day": "日",
+ "days": "日",
+ "week": "週間",
+ "weeks": "週間",
+ "month": "か月",
+ "months": "か月",
+ "year": "年",
+ "years": "年"
},
- "remove": {
- "title": "メンバーを削除",
- "description": "この組織から${name}を削除してもよろしいですか?",
- "confirm_button": "削除",
- "cancel_button": "キャンセル",
- "success": "${name}が組織から削除されました。"
+ "actions": {
+ "menu_label": "アクション",
+ "view_details": "詳細を表示",
+ "assign_role": "ロールを割り当てる",
+ "remove_from_org": "組織から削除"
},
"error": {
"fetch_failed": "メンバーの読み込みに失敗しました。もう一度お試しください。",
- "remove_failed": "メンバーの削除に失敗しました。もう一度お試しください。"
+ "remove_failed": "メンバーの削除に失敗しました。もう一度お試しください。",
+ "assign_role_failed": "ロールの割り当てに失敗しました。もう一度お試しください。"
+ },
+ "detail": {
+ "back_button": "メンバー一覧に戻る",
+ "user_id_label": "ユーザーID:",
+ "tabs": {
+ "details": "詳細",
+ "roles": "ロール"
+ },
+ "user_details": {
+ "title": "ユーザー詳細",
+ "name": "名前",
+ "email": "メール",
+ "phone_number": "電話番号",
+ "provider": "プロバイダー",
+ "created_at": "作成日",
+ "last_login": "最終ログイン"
+ },
+ "actions": {
+ "remove_from_org": {
+ "title": "組織からメンバーを削除",
+ "description": "一度確定すると、この操作は元に戻せません。",
+ "button": "削除",
+ "success": "${memberName} が ${orgName} から削除されました。",
+ "modal": {
+ "title": "${orgName} からメンバーを削除",
+ "description": "\"${memberName}\" をこの組織から削除してもよろしいですか?この操作はアカウントを削除しませんが、アクセスを取り消し、サインインできなくなります。",
+ "cancel_button": "キャンセル",
+ "confirm_button": "削除する"
+ }
+ }
+ },
+ "roles": {
+ "title": "ロール",
+ "description": "${orgName} にアクセスする際のユーザーのロールと権限を管理します。",
+ "assign_button": "ロールを割り当てる",
+ "no_roles_available": "このメンバーに割り当てられるロールはもうありません。",
+ "max_roles_reached": "このメンバーの最大登録数 ${max} に達しました。",
+ "remove_button": "ロールを削除",
+ "remove_button_plural": "ロールを削除",
+ "roles_selected": "${count}件のロールが選択されています",
+ "roles_selected_plural": "${count}件のロールが選択されています",
+ "table": {
+ "name": "名前",
+ "description": "説明",
+ "empty_message": "割り当てられたロールはありません。",
+ "remove_button_label": "ロール ${roleName} を削除する"
+ },
+ "assign_modal": {
+ "title": "ロールを割り当てる",
+ "roles_label": "ロール",
+ "roles_placeholder": "ロールを選択",
+ "submit_button": "割り当て",
+ "cancel_button": "キャンセル",
+ "no_roles_available": "利用可能なすべてのロールがすでに割り当てられています。",
+ "success": "ロールが割り当てられました。",
+ "success_plural": "ロールが割り当てられました。"
+ },
+ "remove_confirm": {
+ "title": "メンバーからロールを削除しますか?",
+ "title_plural": "メンバーからロールを削除しますか?",
+ "description": "\"${memberName}\" から ${roleName} ロールを削除します。",
+ "confirm_button": "削除",
+ "cancel_button": "キャンセル",
+ "success": "ロール \"${roleName}\" がメンバーから削除されました。",
+ "success_plural": "ロール \"${roleNames}\" がメンバーから削除されました。"
+ }
+ },
+ "error": {
+ "fetch_failed": "メンバー詳細の読み込みに失敗しました。もう一度お試しください。",
+ "fetch_roles_failed": "メンバーのロールの読み込みに失敗しました。もう一度お試しください。",
+ "remove_from_org_failed": "組織からのメンバーの削除に失敗しました。もう一度お試しください。",
+ "assign_role_failed": "ロールの割り当てに失敗しました。もう一度お試しください。",
+ "remove_role_failed": "ロールの削除に失敗しました。もう一度お試しください。"
+ }
}
},
"invitation": {
@@ -1127,6 +1225,7 @@
"menu_label": "アクション",
"view_details": "詳細を表示",
"copy_url": "招待URLをコピー",
+ "copied": "コピーしました!",
"revoke_and_resend": "取り消して再送信",
"revoke": "招待を取り消す"
},
@@ -1148,7 +1247,8 @@
"submit_button": "招待を送信",
"creating": "作成中...",
"cancel_button": "キャンセル",
- "success": "${email}に招待を送信しました。"
+ "success": "${email}に招待が送信されました。",
+ "success_bulk": "招待が送信されました。"
},
"details": {
"title": "招待の詳細",
@@ -1180,7 +1280,7 @@
},
"success": {
"url_copied": "招待URLをクリップボードにコピーしました。",
- "invitation_resent": "${email}に招待を再送信しました。"
+ "invitation_resent": "以前の招待が取り消されました。${email}に新しい招待が送信されました。"
},
"error": {
"fetch_failed": "招待の読み込みに失敗しました。もう一度お試しください。",
diff --git a/packages/core/src/services/my-organization/index.ts b/packages/core/src/services/my-organization/index.ts
index deb30e8e6..bd65eeae2 100644
--- a/packages/core/src/services/my-organization/index.ts
+++ b/packages/core/src/services/my-organization/index.ts
@@ -7,4 +7,6 @@
export * from './organization-management';
export * from './idp-management';
export * from './domain-management';
+export * from './member-management/member-management-types';
+export * from './member-management/member-management-constants';
export * from './config';
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
new file mode 100644
index 000000000..66e730291
--- /dev/null
+++ b/packages/core/src/services/my-organization/member-management/member-management-constants.ts
@@ -0,0 +1,21 @@
+/**
+ * Member management constants.
+ * @module member-management-constants
+ * @internal
+ */
+
+export const memberManagementQueryKeys = {
+ all: ['member-management'] as const,
+ invitations: () => [...memberManagementQueryKeys.all, 'invitations'] as const,
+ roles: () => [...memberManagementQueryKeys.all, 'roles'] as const,
+ members: () => [...memberManagementQueryKeys.all, 'members'] as const,
+ memberRoles: (id: string) => [...memberManagementQueryKeys.all, 'member-roles', id] as const,
+ organization: ['organization', 'details'] as const,
+};
+
+export const memberDetailQueryKeys = {
+ all: ['member-detail'] as const,
+ member: (id: string) => [...memberDetailQueryKeys.all, 'member', id] as const,
+ memberRoles: (id: string) => [...memberDetailQueryKeys.all, 'member-roles', id] 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
new file mode 100644
index 000000000..2fa3c7f9b
--- /dev/null
+++ b/packages/core/src/services/my-organization/member-management/member-management-types.ts
@@ -0,0 +1,129 @@
+/**
+ * Member management type definitions for organization member and invitation operations.
+ * @module member-management-types
+ * @internal
+ */
+import type { MyOrganization } from '@auth0/myorganization-js';
+
+/**
+ * Organization member ID type.
+ */
+export type OrgMemberId = MyOrganization.OrgMemberId;
+
+/**
+ * Organization member identity.
+ */
+export interface OrgMemberIdentity {
+ connection?: string;
+ provider?: string;
+ user_id?: string;
+ isSocial?: boolean;
+}
+
+/**
+ * Organization member entity.
+ */
+export interface OrgMember extends MyOrganization.OrgMember {
+ phone_number?: string;
+ identities?: OrgMemberIdentity[];
+}
+
+/**
+ * Response content for listing organization members.
+ */
+export type ListOrganizationMembersResponseContent =
+ MyOrganization.ListOrganizationMembersResponseContent;
+
+/**
+ * Response content for getting a single organization member.
+ */
+export type GetOrganizationMemberResponseContent =
+ MyOrganization.GetOrganizationMemberResponseContent;
+
+/**
+ * Request parameters for listing organization members.
+ */
+export type ListOrganizationMembersRequestParameters =
+ MyOrganization.ListOrganizationMembersRequestParameters;
+
+/**
+ * Response content for getting organization member roles.
+ */
+export type GetOrganizationMemberRolesResponseContent =
+ MyOrganization.GetOrganizationMemberRolesResponseContent;
+
+/**
+ * Invitation ID type.
+ */
+export type InvitationId = MyOrganization.InvitationId;
+
+/**
+ * Member invitation entity.
+ */
+export type MemberInvitation = MyOrganization.MemberInvitation;
+
+/**
+ * Member invitation invitee details.
+ */
+export type MemberInvitationInvitee = MyOrganization.MemberInvitationInvitee;
+
+/**
+ * Member invitation inviter details.
+ */
+export type MemberInvitationInviter = MyOrganization.MemberInvitationInviter;
+
+/**
+ * Response content for listing member invitations.
+ */
+export type ListMembersInvitationsResponseContent =
+ MyOrganization.ListMembersInvitationsResponseContent;
+
+/**
+ * Request parameters for listing member invitations.
+ */
+export type ListMemberInvitationsRequestParameters =
+ MyOrganization.ListMemberInvitationsRequestParameters;
+
+/**
+ * Request content for creating a member invitation.
+ */
+export type CreateMemberInvitationRequestContent =
+ MyOrganization.CreateMemberInvitationRequestContent;
+
+/**
+ * Response content for creating a member invitation.
+ */
+export type CreateMemberInvitationResponseContent =
+ MyOrganization.CreateMemberInvitationResponseContent;
+
+/**
+ * Response content for getting a member invitation.
+ */
+export type GetMemberInvitationResponseContent = MyOrganization.GetMemberInvitationResponseContent;
+
+/**
+ * Request parameters for deleting organization memberships.
+ */
+export type DeleteOrganizationMembersRequestContent =
+ MyOrganization.DeleteOrganizationMembershipsRequestParameters;
+
+/**
+ * Request content for changing roles of an organization member.
+ */
+export type OrganizationMemberRolesChangeRequestContent =
+ MyOrganization.OrganizationMemberRolesChangeRequestContent;
+
+/**
+ * Organization role available for binding to members and invitations.
+ */
+export type Role = MyOrganization.Role;
+
+/**
+ * Organization role ID.
+ */
+export type RoleId = MyOrganization.RoleId;
+
+/**
+ * Response content for listing organization roles.
+ */
+export type ListRolesResponseContent = MyOrganization.ListRolesResponseContent;
diff --git a/packages/react/src/components/auth0/my-organization/__tests__/organization-member-detail.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-detail.test.tsx
new file mode 100644
index 000000000..0e7795a7e
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-detail.test.tsx
@@ -0,0 +1,1059 @@
+import type { ComponentAction } from '@auth0/universal-components-core';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
+
+import {
+ OrganizationMemberDetail,
+ OrganizationMemberDetailView,
+} from '@/components/auth0/my-organization/organization-member-detail';
+import * as useCoreClientModule from '@/hooks/shared/use-core-client';
+import {
+ createMockMember,
+ createMockMemberRoles,
+ createMockAvailableRoles,
+ createMockOrganizationMemberDetailProps,
+ createMockOrganizationMemberDetailViewProps,
+ noModal,
+} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+import { renderWithProviders } from '@/tests/utils/test-provider';
+import { mockCore, mockToast } from '@/tests/utils/test-setup';
+import type { MemberDetailModalState } from '@/types/my-organization/member-management/organization-member-detail-types';
+
+mockToast();
+const { initMockCoreClient } = mockCore();
+
+const waitForComponentToLoad = async () => {
+ return await screen.findByText('member.detail.back_button');
+};
+
+describe('OrganizationMemberDetail', () => {
+ const mockMember = createMockMember();
+ let mockCoreClient: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockCoreClient = initMockCoreClient();
+
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (apiService.organization.members.get as ReturnType).mockResolvedValue(mockMember);
+ (apiService.organization.configuration.get as ReturnType).mockResolvedValue({
+ allowed_strategies: ['samlp', 'oidc'],
+ connection_deletion_behavior: 'allow',
+ allowed_roles: createMockAvailableRoles(),
+ });
+
+ vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({
+ coreClient: mockCoreClient,
+ });
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('should render the back button', async () => {
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ expect(screen.getByText('member.detail.back_button')).toBeInTheDocument();
+ });
+
+ it('should render the member display name in the header', async () => {
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ expect(screen.getByRole('heading', { name: mockMember.name! })).toBeInTheDocument();
+ });
+
+ it('should render member user_id as a badge', async () => {
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ expect(screen.getByText('auth0|testuser123')).toBeInTheDocument();
+ });
+
+ it('should render Details and Roles tabs', async () => {
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ expect(screen.getByText('member.detail.tabs.details')).toBeInTheDocument();
+ expect(screen.getByText('member.detail.tabs.roles')).toBeInTheDocument();
+ });
+
+ it('should default to the details tab being active', async () => {
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const detailsTab = screen.getByRole('tab', { name: 'member.detail.tabs.details' });
+ expect(detailsTab).toHaveAttribute('data-state', 'active');
+ });
+ });
+
+ describe('member data', () => {
+ it('should show initials from member name with multiple words', async () => {
+ const member = createMockMember({ name: 'Test User' });
+ (
+ mockCoreClient.getMyOrganizationApiClient().organization.members.get as ReturnType<
+ typeof vi.fn
+ >
+ ).mockResolvedValue(member);
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ expect(screen.getByText('TU')).toBeInTheDocument();
+ });
+
+ it('should show single initial when name has one word', async () => {
+ const member = createMockMember({ name: 'Alice' });
+ (
+ mockCoreClient.getMyOrganizationApiClient().organization.members.get as ReturnType<
+ typeof vi.fn
+ >
+ ).mockResolvedValue(member);
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ expect(screen.getByText('A')).toBeInTheDocument();
+ });
+
+ it('should show "U" initials when member has no name and no user_id', async () => {
+ const member = createMockMember({ name: undefined, user_id: '' });
+ (
+ mockCoreClient.getMyOrganizationApiClient().organization.members.get as ReturnType<
+ typeof vi.fn
+ >
+ ).mockResolvedValue(member);
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ expect(screen.getByText('U')).toBeInTheDocument();
+ });
+
+ it('should use user_id as display name when name is missing', async () => {
+ const member = createMockMember({ name: undefined, user_id: 'auth0|nameless' });
+ (
+ mockCoreClient.getMyOrganizationApiClient().organization.members.get as ReturnType<
+ typeof vi.fn
+ >
+ ).mockResolvedValue(member);
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ expect(screen.getByRole('heading', { name: 'auth0|nameless' })).toBeInTheDocument();
+ });
+ });
+
+ describe('onBack', () => {
+ it('when onBack is provided and back button is clicked, should call onBack', async () => {
+ const user = userEvent.setup();
+ const onBack = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const backButton = screen.getByRole('button', { name: /member.detail.back_button/i });
+ await user.click(backButton);
+
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('when onBack is not provided, clicking back button should not throw', async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const backButton = screen.getByRole('button', { name: /member.detail.back_button/i });
+ await expect(user.click(backButton)).resolves.not.toThrow();
+ });
+ });
+
+ describe('tab navigation', () => {
+ it('when user clicks the Roles tab, should show roles tab content', async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const rolesTab = screen.getByRole('tab', { name: 'member.detail.tabs.roles' });
+ await user.click(rolesTab);
+
+ await waitFor(() => {
+ expect(rolesTab).toHaveAttribute('data-state', 'active');
+ });
+ });
+
+ it('when user clicks back to Details tab, should show details tab content', async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const rolesTab = screen.getByRole('tab', { name: 'member.detail.tabs.roles' });
+ await user.click(rolesTab);
+
+ const detailsTab = screen.getByRole('tab', { name: 'member.detail.tabs.details' });
+ await user.click(detailsTab);
+
+ await waitFor(() => {
+ expect(detailsTab).toHaveAttribute('data-state', 'active');
+ });
+ });
+ });
+
+ describe('removeFromOrgAction', () => {
+ describe('removeFromOrgAction.onBefore', () => {
+ describe('when returns true', () => {
+ it('should call memberships.deleteMemberships and call onBack', async () => {
+ const user = userEvent.setup();
+ const onBack = vi.fn();
+ const removeFromOrgAction: ComponentAction = {
+ disabled: false,
+ onBefore: vi.fn(() => true),
+ onAfter: vi.fn(),
+ };
+
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (
+ apiService.organization.memberships.deleteMemberships as ReturnType
+ ).mockResolvedValue(undefined);
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const removeButton = screen.getByRole('button', {
+ name: /member.detail.actions.remove_from_org.button/i,
+ });
+ await user.click(removeButton);
+
+ const confirmButton = await screen.findByRole('button', {
+ name: /member.detail.actions.remove_from_org.modal.confirm_button/i,
+ });
+ await user.click(confirmButton);
+
+ await waitFor(() => {
+ expect(apiService.organization.memberships.deleteMemberships).toHaveBeenCalledWith({
+ members: ['auth0|testuser123'],
+ });
+ expect(onBack).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when returns false', () => {
+ it('should not call memberships.deleteMemberships', async () => {
+ const user = userEvent.setup();
+ const removeFromOrgAction: ComponentAction = {
+ disabled: false,
+ onBefore: vi.fn(() => false),
+ onAfter: vi.fn(),
+ };
+
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (
+ apiService.organization.memberships.deleteMemberships as ReturnType
+ ).mockResolvedValue(undefined);
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const removeButton = screen.getByRole('button', {
+ name: /member.detail.actions.remove_from_org.button/i,
+ });
+ await user.click(removeButton);
+
+ const confirmButton = await screen.findByRole('button', {
+ name: /member.detail.actions.remove_from_org.modal.confirm_button/i,
+ });
+ await user.click(confirmButton);
+
+ await waitFor(() => {
+ expect(removeFromOrgAction.onBefore).toHaveBeenCalled();
+ });
+
+ expect(apiService.organization.memberships.deleteMemberships).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('removeFromOrgAction.onAfter', () => {
+ it('when remove from org succeeds, should call onAfter with userId', async () => {
+ const user = userEvent.setup();
+ const removeFromOrgAction: ComponentAction = {
+ disabled: false,
+ onBefore: vi.fn(() => true),
+ onAfter: vi.fn(),
+ };
+
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (
+ apiService.organization.memberships.deleteMemberships as ReturnType
+ ).mockResolvedValue(undefined);
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const removeButton = screen.getByRole('button', {
+ name: /member.detail.actions.remove_from_org.button/i,
+ });
+ await user.click(removeButton);
+
+ const confirmButton = await screen.findByRole('button', {
+ name: /member.detail.actions.remove_from_org.modal.confirm_button/i,
+ });
+ await user.click(confirmButton);
+
+ await waitFor(() => {
+ expect(removeFromOrgAction.onAfter).toHaveBeenCalledWith('auth0|testuser123');
+ });
+ });
+ });
+ });
+
+ describe('assignRolesAction', () => {
+ describe('when assign roles button is clicked', () => {
+ it('should open the assign roles modal', async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const rolesTab = screen.getByRole('tab', { name: 'member.detail.tabs.roles' });
+ await user.click(rolesTab);
+
+ const assignButton = await screen.findByRole('button', {
+ name: /member.detail.roles.assign_button/i,
+ });
+ await user.click(assignButton);
+
+ await screen.findByText('member.detail.roles.assign_modal.title');
+ });
+ });
+
+ describe('assignRolesAction.onBefore', () => {
+ describe('when returns true', () => {
+ it('should call members.roles.assign', async () => {
+ const user = userEvent.setup();
+ const assignRolesAction: ComponentAction<{ userId: string; roleIds: string[] }> = {
+ disabled: false,
+ onBefore: vi.fn(() => true),
+ onAfter: vi.fn(),
+ };
+
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (
+ apiService.organization.members.roles.assign as ReturnType
+ ).mockResolvedValue({});
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const rolesTab = screen.getByRole('tab', { name: 'member.detail.tabs.roles' });
+ await user.click(rolesTab);
+
+ const assignButton = await screen.findByRole('button', {
+ name: /member.detail.roles.assign_button/i,
+ });
+ await user.click(assignButton);
+
+ await screen.findByText('member.detail.roles.assign_modal.title');
+
+ const comboboxInput = screen.getByPlaceholderText(
+ 'member.detail.roles.assign_modal.roles_placeholder',
+ );
+ await user.click(comboboxInput);
+
+ await user.click(await screen.findByRole('button', { name: /admin/i }));
+
+ await user.click(
+ screen.getByRole('button', {
+ name: /member.detail.roles.assign_modal.submit_button/i,
+ }),
+ );
+
+ await waitFor(() => {
+ expect(assignRolesAction.onBefore).toHaveBeenCalled();
+ expect(apiService.organization.members.roles.assign).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when returns false', () => {
+ it('should not call members.roles.assign', async () => {
+ const user = userEvent.setup();
+ const assignRolesAction: ComponentAction<{ userId: string; roleIds: string[] }> = {
+ disabled: false,
+ onBefore: vi.fn(() => false),
+ onAfter: vi.fn(),
+ };
+
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (
+ apiService.organization.members.roles.assign as ReturnType
+ ).mockResolvedValue({});
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const rolesTab = screen.getByRole('tab', { name: 'member.detail.tabs.roles' });
+ await user.click(rolesTab);
+
+ const assignButton = await screen.findByRole('button', {
+ name: /member.detail.roles.assign_button/i,
+ });
+ await user.click(assignButton);
+
+ await screen.findByText('member.detail.roles.assign_modal.title');
+
+ const comboboxInput = screen.getByPlaceholderText(
+ 'member.detail.roles.assign_modal.roles_placeholder',
+ );
+ await user.click(comboboxInput);
+
+ await user.click(await screen.findByRole('button', { name: /admin/i }));
+
+ await user.click(
+ screen.getByRole('button', {
+ name: /member.detail.roles.assign_modal.submit_button/i,
+ }),
+ );
+
+ await waitFor(() => {
+ expect(assignRolesAction.onBefore).toHaveBeenCalled();
+ });
+
+ expect(apiService.organization.members.roles.assign).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe('removeRolesAction', () => {
+ const memberWithRoles = createMockMember({ roles: createMockMemberRoles() });
+
+ beforeEach(() => {
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (apiService.organization.members.roles.list as ReturnType).mockResolvedValue({
+ data: createMockMemberRoles(),
+ });
+ });
+
+ describe('removeRolesAction.onBefore', () => {
+ describe('when returns true', () => {
+ it('should call members.roles.unassign', async () => {
+ const user = userEvent.setup();
+ const removeRolesAction: ComponentAction<{ userId: string; roleIds: string[] }> = {
+ disabled: false,
+ onBefore: vi.fn(() => true),
+ onAfter: vi.fn(),
+ };
+
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (apiService.organization.members.get as ReturnType).mockResolvedValue(
+ memberWithRoles,
+ );
+ (
+ apiService.organization.members.roles.unassign as ReturnType
+ ).mockResolvedValue({});
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const rolesTab = screen.getByRole('tab', { name: 'member.detail.tabs.roles' });
+ await user.click(rolesTab);
+
+ const removeRoleButtons = await screen.findAllByRole('button', {
+ name: /member.detail.roles.remove_confirm.confirm_button|remove/i,
+ });
+
+ await user.click(removeRoleButtons[0]!);
+
+ const confirmButton = await screen.findByRole('button', {
+ name: /member.detail.roles.remove_confirm.confirm_button/i,
+ });
+ await user.click(confirmButton);
+
+ await waitFor(() => {
+ expect(apiService.organization.members.roles.unassign).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when returns false', () => {
+ it('should not call members.roles.unassign', async () => {
+ const user = userEvent.setup();
+ const removeRolesAction: ComponentAction<{ userId: string; roleIds: string[] }> = {
+ disabled: false,
+ onBefore: vi.fn(() => false),
+ onAfter: vi.fn(),
+ };
+
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (apiService.organization.members.get as ReturnType).mockResolvedValue(
+ memberWithRoles,
+ );
+ (
+ apiService.organization.members.roles.unassign as ReturnType
+ ).mockResolvedValue({});
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ const rolesTab = screen.getByRole('tab', { name: 'member.detail.tabs.roles' });
+ await user.click(rolesTab);
+
+ const removeRoleButtons = await screen.findAllByRole('button', {
+ name: /member.detail.roles.remove_confirm.confirm_button|remove/i,
+ });
+
+ await user.click(removeRoleButtons[0]!);
+
+ const confirmButton = await screen.findByRole('button', {
+ name: /member.detail.roles.remove_confirm.confirm_button/i,
+ });
+ await user.click(confirmButton);
+
+ await waitFor(() => {
+ expect(removeRolesAction.onBefore).toHaveBeenCalled();
+ });
+
+ expect(apiService.organization.members.roles.unassign).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe('member fetch error state', () => {
+ it('when members.get fails with a backend message, should show backend message in place of tabs', async () => {
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (apiService.organization.members.get as ReturnType).mockRejectedValue(
+ Object.assign(new Error(), { body: { detail: 'Organization or member not found.' } }),
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Organization or member not found.')).toBeInTheDocument();
+ });
+
+ expect(
+ screen.queryByRole('tab', { name: 'member.detail.tabs.details' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('when members.get fails without a backend message, should show fallback message', async () => {
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (apiService.organization.members.get as ReturnType).mockRejectedValue(
+ new Error(),
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('member.detail.error.fetch_failed')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('error handling', () => {
+ it('when members.get fails, should not call members.roles.list', async () => {
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (apiService.organization.members.get as ReturnType).mockRejectedValue(
+ Object.assign(new Error(), {
+ body: { detail: 'User is not a member of this organization.' },
+ }),
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('User is not a member of this organization.')).toBeInTheDocument();
+ });
+
+ expect(apiService.organization.members.roles.list).not.toHaveBeenCalled();
+ });
+
+ it('when members.roles.list fails, should show a fetch_roles_failed toast', async () => {
+ const { mockedShowToast } = mockToast();
+ const apiService = mockCoreClient.getMyOrganizationApiClient();
+ (apiService.organization.members.roles.list as ReturnType).mockRejectedValue(
+ new Error('Network error'),
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockedShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
+ });
+ });
+ });
+
+ describe('customMessages', () => {
+ it('should override the back button text', async () => {
+ renderWithProviders(
+ ,
+ );
+
+ await screen.findByText('Go Back');
+
+ expect(screen.getByText('Go Back')).toBeInTheDocument();
+ expect(screen.queryByText('member.detail.back_button')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('styling', () => {
+ describe('when styling.classes are provided', () => {
+ it('should apply custom class to root div', async () => {
+ renderWithProviders(
+ ,
+ );
+
+ await waitForComponentToLoad();
+
+ expect(document.querySelector('.custom-root-class')).toBeInTheDocument();
+ });
+ });
+ });
+});
+
+describe('OrganizationMemberDetailView', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('should render header with member name', () => {
+ const props = createMockOrganizationMemberDetailViewProps();
+ renderWithProviders( );
+
+ expect(screen.getByRole('heading', { name: props.member!.name! })).toBeInTheDocument();
+ });
+
+ it('should render back button', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('member.detail.back_button')).toBeInTheDocument();
+ });
+
+ it('should render both tabs', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('tab', { name: 'member.detail.tabs.details' })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'member.detail.tabs.roles' })).toBeInTheDocument();
+ });
+
+ it('should render details tab content by default', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('tab', { name: 'member.detail.tabs.details' })).toHaveAttribute(
+ 'data-state',
+ 'active',
+ );
+ });
+
+ it('should render roles tab content when activeTab is roles', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('tab', { name: 'member.detail.tabs.roles' })).toHaveAttribute(
+ 'data-state',
+ 'active',
+ );
+ });
+ });
+
+ describe('header', () => {
+ it('should display member initials in avatar circle', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('TU')).toBeInTheDocument();
+ });
+
+ it('should display "U" when member name and user_id are both empty', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('U')).toBeInTheDocument();
+ });
+
+ it('should display user_id badge', () => {
+ const member = createMockMember({ user_id: 'auth0|testuser123' });
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('auth0|testuser123')).toBeInTheDocument();
+ });
+
+ it('should not display user_id badge when user_id is empty', () => {
+ const member = createMockMember({ user_id: '' });
+ renderWithProviders(
+ ,
+ );
+
+ const badges = document.querySelectorAll('.font-mono');
+ expect(badges.length).toBe(0);
+ });
+ });
+
+ describe('MemberRemoveFromOrgModal', () => {
+ it('when modalState is removeFromOrg, should render the modal', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByText('member.detail.actions.remove_from_org.modal.title'),
+ ).toBeInTheDocument();
+ });
+
+ it('when modalState is null, should not render the modal', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByText('member.detail.actions.remove_from_org.modal.title'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('when cancel is clicked, should call closeModal', async () => {
+ const user = userEvent.setup();
+ const closeModal = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const cancelButton = screen.getByRole('button', {
+ name: 'member.detail.actions.remove_from_org.modal.cancel_button',
+ });
+ await user.click(cancelButton);
+
+ expect(closeModal).toHaveBeenCalledTimes(1);
+ });
+
+ it('when modal confirm is clicked, should call handleRemoveFromOrgConfirm', async () => {
+ const user = userEvent.setup();
+ const handleRemoveFromOrgConfirm = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const confirmButton = screen.getByRole('button', {
+ name: 'member.detail.actions.remove_from_org.modal.confirm_button',
+ });
+ await user.click(confirmButton);
+
+ expect(handleRemoveFromOrgConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('when isRemovingFromOrg is true, modal confirm button should show loading indicator', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Loading...' })).toBeInTheDocument();
+ });
+ });
+
+ describe('tab switching', () => {
+ it('when user clicks Roles tab, should call setActiveTab with roles', async () => {
+ const user = userEvent.setup();
+ const setActiveTab = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const rolesTab = screen.getByRole('tab', { name: 'member.detail.tabs.roles' });
+ await user.click(rolesTab);
+
+ expect(setActiveTab).toHaveBeenCalledWith('roles');
+ });
+
+ it('when user clicks Details tab while on roles, should call setActiveTab with details', async () => {
+ const user = userEvent.setup();
+ const setActiveTab = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const detailsTab = screen.getByRole('tab', { name: 'member.detail.tabs.details' });
+ await user.click(detailsTab);
+
+ expect(setActiveTab).toHaveBeenCalledWith('details');
+ });
+ });
+
+ describe('back button', () => {
+ it('when back button is clicked, should call handleBack', async () => {
+ const user = userEvent.setup();
+ const handleBack = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const backButton = screen.getByRole('button', { name: /member.detail.back_button/i });
+ await user.click(backButton);
+
+ expect(handleBack).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('customMessages', () => {
+ it('should render custom tab labels', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('Go Back')).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Info' })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Permissions' })).toBeInTheDocument();
+ });
+ });
+
+ describe('memberError', () => {
+ it('when memberError is set, should display the error message', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('Member not found.')).toBeInTheDocument();
+ });
+
+ it('when memberError is set, should not render tabs', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByRole('tab', { name: 'member.detail.tabs.details' }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('tab', { name: 'member.detail.tabs.roles' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('when memberError is set, should still render the back button', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('member.detail.back_button')).toBeInTheDocument();
+ });
+ });
+
+ describe('member is null', () => {
+ it('should render the back button even when member is null', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('member.detail.back_button')).toBeInTheDocument();
+ });
+
+ it('should show "U" initials when member is null', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('U')).toBeInTheDocument();
+ });
+ });
+});
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
new file mode 100644
index 000000000..dbd14eb06
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.test.tsx
@@ -0,0 +1,552 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ OrganizationMemberManagement,
+ OrganizationMemberManagementView,
+} from '@/components/auth0/my-organization/organization-member-management';
+import { useOrganizationMemberManagement } from '@/hooks/my-organization/use-organization-member-management';
+import { createMockPendingInvitation } from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks';
+import {
+ createMockMember,
+ createMockRoleOptions,
+} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+import { renderWithProviders } from '@/tests/utils/test-provider';
+import type {
+ OrganizationMemberManagementProps,
+ OrganizationMemberManagementViewProps,
+ UseOrganizationMemberManagementResult,
+} from '@/types/my-organization/member-management/organization-member-management-types';
+
+vi.mock('@/hooks/my-organization/use-organization-member-management', () => ({
+ useOrganizationMemberManagement: vi.fn(),
+}));
+
+vi.mock('@/hooks/shared/use-theme', () => ({
+ useTheme: () => ({ isDarkMode: false }),
+}));
+
+vi.mock('@/components/auth0/shared/header', () => ({
+ Header: ({ title, description, actions = [] }: any) => (
+
+
{title}
+
{description}
+ {actions.map((action: any) => (
+
+ {action.label}
+
+ ))}
+
+ ),
+}));
+
+vi.mock('@/components/auth0/shared/styled-scope', () => ({
+ StyledScope: ({ children }: any) => {children}
,
+}));
+
+vi.mock(
+ '@/components/auth0/my-organization/shared/member-management/members/members-table/organization-member-table',
+ () => ({
+ OrganizationMemberTable: ({ members, onAssignRole, onRemoveFromOrg, className }: any) => (
+
+ members:{members.length}
+ onAssignRole?.(members[0])}>assign-role
+ onRemoveFromOrg?.(members[0])}>remove-from-org
+
+ ),
+ }),
+);
+
+vi.mock(
+ '@/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table',
+ () => ({
+ OrganizationInvitationTable: ({
+ invitations,
+ onView,
+ onCopyUrl,
+ onRevoke,
+ onRevokeAndResend,
+ className,
+ }: any) => (
+
+ invitations:{invitations.length}
+ onView?.(invitations[0])}>view-invitation
+ onCopyUrl?.(invitations[0])}>copy-url
+ onRevoke?.(invitations[0])}>revoke
+ onRevokeAndResend?.(invitations[0])}>revoke-resend
+
+ ),
+ }),
+);
+
+vi.mock(
+ '@/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal',
+ () => ({
+ OrganizationInvitationCreateModal: ({ isOpen, onCreate, onClose }: any) => (
+
+ open:{String(isOpen)}
+ onCreate?.({ invitees: [{ email: 'x@example.com' }] })}>
+ submit-create
+
+ close-create
+
+ ),
+ }),
+);
+
+vi.mock(
+ '@/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal',
+ () => ({
+ OrganizationInvitationDetailsModal: ({
+ isOpen,
+ onClose,
+ onCopyUrl,
+ onRevoke,
+ onResend,
+ }: any) => (
+
+ open:{String(isOpen)}
+ close-details
+ onCopyUrl?.({ id: 'inv_1' })}>details-copy
+ onRevoke?.({ id: 'inv_1' })}>details-revoke
+ onResend?.({ id: 'inv_1' })}>details-resend
+
+ ),
+ }),
+);
+
+vi.mock(
+ '@/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal',
+ () => ({
+ OrganizationInvitationRevokeModal: ({ isOpen, isRevokeAndResend, onConfirm, onClose }: any) => (
+
+ open:{String(isOpen)}
+
+ {isRevokeAndResend ? 'confirm-revoke-resend' : 'confirm-revoke'}
+
+
+ {isRevokeAndResend ? 'close-revoke-resend' : 'close-revoke'}
+
+
+ ),
+ }),
+);
+
+vi.mock(
+ '@/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-assign-roles-modal',
+ () => ({
+ OrganizationMemberAssignRolesModal: ({
+ isOpen,
+ selectedMember,
+ assignedRoles,
+ availableRoles,
+ isLoading,
+ onAssign,
+ onClose,
+ className,
+ }: any) => (
+
+ open:{String(isOpen)}
+ member:{selectedMember?.user_id ?? 'none'}
+ assigned:{assignedRoles.length}
+ available:{availableRoles.length}
+ loading:{String(isLoading)}
+ onAssign?.(['role_admin'], selectedMember?.user_id ?? null)}>
+ confirm-assign-role
+
+ close-assign-role
+
+ ),
+ }),
+);
+
+vi.mock(
+ '@/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-remove-from-org-modal',
+ () => ({
+ MemberRemoveFromOrgModal: ({
+ isOpen,
+ memberName,
+ memberUserId,
+ orgName,
+ isLoading,
+ onConfirm,
+ onClose,
+ className,
+ }: any) => (
+
+ open:{String(isOpen)}
+ member:{memberUserId ?? 'none'}
+ memberName:{memberName ?? 'none'}
+ orgName:{orgName ?? 'none'}
+ loading:{String(isLoading)}
+ onConfirm?.(memberUserId)}>confirm-remove-from-org
+ close-remove-from-org
+
+ ),
+ }),
+);
+
+vi.mock('@/components/auth0/shared/gate-keeper/gate-keeper', () => ({
+ GateKeeper: ({ isLoading, children }: any) => (
+
+ {children}
+
+ ),
+}));
+
+const mockedUseOrganizationMemberManagement = vi.mocked(useOrganizationMemberManagement);
+
+const createMockMemberManagementResult = (
+ overrides: Partial = {},
+): UseOrganizationMemberManagementResult => {
+ const member = createMockMember();
+ const invitation = createMockPendingInvitation();
+
+ return {
+ activeTab: 'members',
+ availableRoles: createMockRoleOptions(),
+ availableProviders: [],
+ members: [member],
+ invitations: [invitation],
+ isFetchingInvitations: false,
+ isInitialLoading: false,
+ isFetchingMembers: false,
+ isFetchingAvailableRoles: false,
+ isCreatingInvitation: false,
+ isRevokingInvitation: false,
+ isResendingInvitation: false,
+ invitationPagination: {
+ pageSize: 10,
+ currentPage: 1,
+ totalItems: 1,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ },
+ memberPagination: {
+ pageSize: 10,
+ currentPage: 1,
+ totalItems: 1,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ },
+ invitationFilters: {},
+ invitationSortConfig: { key: null, direction: 'asc' },
+ memberFilters: {},
+ memberSortConfig: { key: null, direction: 'asc' },
+ modalState: { type: null },
+ isRemovingFromOrg: false,
+ isAssigningRoles: false,
+ setActiveTab: vi.fn(),
+ openModal: vi.fn(),
+ closeModal: vi.fn(),
+ handleCreateSubmit: vi.fn(),
+ handleRevokeConfirm: vi.fn(),
+ handleRevokeResendConfirm: vi.fn(),
+ handleCopyUrl: vi.fn(),
+ handleNextPage: vi.fn(),
+ handlePreviousPage: vi.fn(),
+ handlePageSizeChange: vi.fn(),
+ handleSortChange: vi.fn(),
+ handleRoleFilterChange: vi.fn(),
+ handleViewMemberDetails: vi.fn(),
+ handleAssignRolesSubmit: vi.fn(),
+ handleRemoveFromOrgConfirm: vi.fn(),
+ ...overrides,
+ };
+};
+
+const createMockViewProps = (
+ overrides: Partial = {},
+): OrganizationMemberManagementViewProps => ({
+ ...createMockMemberManagementResult(),
+ styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} },
+ customMessages: {},
+ hideHeader: false,
+ readOnly: false,
+ ...overrides,
+});
+
+const createMockComponentProps = (
+ overrides: Partial = {},
+): OrganizationMemberManagementProps => ({
+ styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} },
+ customMessages: {},
+ hideHeader: false,
+ readOnly: false,
+ ...overrides,
+});
+
+describe('OrganizationMemberManagementView', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders the header and invite action when not read-only', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText('header.title')).toBeInTheDocument();
+ expect(screen.getByText('header.description')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'invite_button' })).toBeInTheDocument();
+ });
+
+ it('does not render the header when hideHeader is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByText('header.title')).not.toBeInTheDocument();
+ });
+
+ it('does not render invite action when readOnly is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByRole('button', { name: 'invite_button' })).not.toBeInTheDocument();
+ });
+
+ it('opens the create invitation modal when invite button is clicked', async () => {
+ const user = userEvent.setup();
+ const openModal = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'invite_button' }));
+
+ expect(openModal).toHaveBeenCalledWith({ type: 'create' });
+ });
+
+ it('opens member assign and remove modals from the member table callbacks', async () => {
+ const user = userEvent.setup();
+ const member = createMockMember();
+ const openModal = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'assign-role' }));
+ await user.click(screen.getByRole('button', { name: 'remove-from-org' }));
+
+ expect(openModal).toHaveBeenNthCalledWith(1, { type: 'assignRole', member });
+ expect(openModal).toHaveBeenNthCalledWith(2, { type: 'removeFromOrg', member });
+ });
+
+ it('renders invitation tab content and opens invitation modals from callbacks', async () => {
+ const user = userEvent.setup();
+ const invitation = createMockPendingInvitation();
+ const openModal = vi.fn();
+ const setActiveTab = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId('invitation-table')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('tab', { name: 'tabs.members' }));
+ expect(setActiveTab).toHaveBeenCalledWith('members');
+
+ await user.click(screen.getByRole('button', { name: 'view-invitation' }));
+ await user.click(screen.getByRole('button', { name: 'revoke' }));
+ await user.click(screen.getByRole('button', { name: 'revoke-resend' }));
+
+ expect(openModal).toHaveBeenCalledWith({ type: 'details', invitation });
+ expect(openModal).toHaveBeenCalledWith({ type: 'revoke', invitation });
+ expect(openModal).toHaveBeenCalledWith({ type: 'revokeResend', invitation });
+ });
+
+ it('omits destructive invitation callbacks in read-only mode', async () => {
+ const user = userEvent.setup();
+ const openModal = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'revoke' }));
+ await user.click(screen.getByRole('button', { name: 'revoke-resend' }));
+
+ expect(openModal).not.toHaveBeenCalled();
+ });
+
+ describe('assign role modal', () => {
+ it('is closed by default with no selected member', () => {
+ renderWithProviders( );
+
+ const modal = screen.getByTestId('assign-role-modal');
+ expect(modal).toHaveTextContent('open:false');
+ expect(modal).toHaveTextContent('member:none');
+ expect(modal).toHaveTextContent('assigned:0');
+ });
+
+ it('opens with the selected member and their assigned roles when modalState is assignRole', () => {
+ const member = createMockMember();
+ renderWithProviders(
+ ,
+ );
+
+ const modal = screen.getByTestId('assign-role-modal');
+ expect(modal).toHaveTextContent('open:true');
+ expect(modal).toHaveTextContent(`member:${member.user_id}`);
+ expect(modal).toHaveTextContent(`assigned:${(member.roles ?? []).length}`);
+ });
+
+ it('reflects loading state when either fetching roles or assigning', () => {
+ const { unmount } = renderWithProviders(
+ ,
+ );
+ expect(screen.getByTestId('assign-role-modal')).toHaveTextContent('loading:true');
+ unmount();
+
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByTestId('assign-role-modal')).toHaveTextContent('loading:true');
+ });
+
+ it('invokes handleAssignRolesSubmit on confirm and closeModal on close', async () => {
+ const user = userEvent.setup();
+ const member = createMockMember();
+ const handleAssignRolesSubmit = vi.fn();
+ const closeModal = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'confirm-assign-role' }));
+ expect(handleAssignRolesSubmit).toHaveBeenCalledWith(['role_admin'], member.user_id);
+
+ await user.click(screen.getByRole('button', { name: 'close-assign-role' }));
+ expect(closeModal).toHaveBeenCalled();
+ });
+ });
+
+ describe('remove from org modal', () => {
+ it('is closed by default with no selected member', () => {
+ renderWithProviders( );
+
+ const modal = screen.getByTestId('remove-from-org-modal');
+ expect(modal).toHaveTextContent('open:false');
+ expect(modal).toHaveTextContent('member:none');
+ });
+
+ it('opens with the selected member info when modalState is removeFromOrg', () => {
+ const member = createMockMember();
+ renderWithProviders(
+ ,
+ );
+
+ const modal = screen.getByTestId('remove-from-org-modal');
+ expect(modal).toHaveTextContent('open:true');
+ expect(modal).toHaveTextContent(`member:${member.user_id}`);
+ expect(modal).toHaveTextContent(`memberName:${member.name}`);
+ expect(modal).toHaveTextContent('orgName:Acme Inc');
+ });
+
+ it('reflects loading state when isRemovingFromOrg is true', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByTestId('remove-from-org-modal')).toHaveTextContent('loading:true');
+ });
+
+ it('invokes handleRemoveFromOrgConfirm with userId on confirm and closeModal on close', async () => {
+ const user = userEvent.setup();
+ const member = createMockMember();
+ const handleRemoveFromOrgConfirm = vi.fn();
+ const closeModal = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'confirm-remove-from-org' }));
+ expect(handleRemoveFromOrgConfirm).toHaveBeenCalledWith(member.user_id);
+
+ await user.click(screen.getByRole('button', { name: 'close-remove-from-org' }));
+ expect(closeModal).toHaveBeenCalled();
+ });
+ });
+});
+
+describe('OrganizationMemberManagement', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('calls the hook with component props and passes loading state to GateKeeper', () => {
+ mockedUseOrganizationMemberManagement.mockReturnValue(
+ createMockMemberManagementResult({ isInitialLoading: true }),
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(mockedUseOrganizationMemberManagement).toHaveBeenCalledWith(
+ expect.objectContaining({
+ customMessages: {},
+ readOnly: true,
+ createInvitationAction: undefined,
+ revokeInvitationAction: undefined,
+ resendInvitationAction: undefined,
+ viewMemberDetailsAction: undefined,
+ assignRolesAction: undefined,
+ removeFromOrgAction: undefined,
+ }),
+ );
+
+ expect(screen.getByTestId('gatekeeper')).toHaveAttribute('data-loading', 'true');
+ });
+
+ it('renders the view content through the container with default props', () => {
+ mockedUseOrganizationMemberManagement.mockReturnValue(createMockMemberManagementResult());
+
+ renderWithProviders( );
+
+ expect(screen.getByText('header.title')).toBeInTheDocument();
+ expect(screen.getByTestId('member-table')).toBeInTheDocument();
+ expect(screen.getByTestId('create-modal')).toBeInTheDocument();
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/organization-member-detail.tsx b/packages/react/src/components/auth0/my-organization/organization-member-detail.tsx
new file mode 100644
index 000000000..2d7016e11
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/organization-member-detail.tsx
@@ -0,0 +1,244 @@
+/**
+ * Organization member detail component.
+ * @module organization-member-detail
+ */
+
+import { getComponentStyles } from '@auth0/universal-components-core';
+import { ArrowLeft } from 'lucide-react';
+import * as React from 'react';
+
+import { GateKeeper } from '../shared/gate-keeper/gate-keeper';
+
+import { MemberRemoveFromOrgModal } from '@/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-remove-from-org-modal';
+import { OrganizationMemberEditDetailsTab } from '@/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-details-tab';
+import { OrganizationMemberEditRolesTab } from '@/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-roles-tab';
+import { StyledScope } from '@/components/auth0/shared/styled-scope';
+import { Avatar, AvatarFallback } from '@/components/ui/avatar';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { useOrganizationMemberDetail } from '@/hooks/my-organization/use-member-detail';
+import { useTheme } from '@/hooks/shared/use-theme';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import { cn } from '@/lib/utils';
+import { getInitials } from '@/lib/utils/my-organization/member-management/member-management-utils';
+import type {
+ MemberDetailHeaderProps,
+ OrganizationMemberDetailProps,
+ OrganizationMemberDetailViewProps,
+} from '@/types/my-organization/member-management/organization-member-detail-types';
+
+/**
+ * Member detail header component.
+ * @param props - Component props containing state and handlers
+ * @returns The rendered header element
+ */
+function Header({
+ member,
+ styling,
+ customMessages,
+ handleBack,
+}: MemberDetailHeaderProps): React.JSX.Element {
+ const { isDarkMode } = useTheme();
+ const { t } = useTranslator('member_management', customMessages);
+ const currentStyles = React.useMemo(
+ () => getComponentStyles(styling, isDarkMode),
+ [styling, isDarkMode],
+ );
+
+ const userId = member?.user_id ?? '';
+ const displayName = member?.name ?? userId;
+ const initials = getInitials(displayName);
+
+ return (
+
+
+
+ {t('member.detail.back_button')}
+
+
+
+
+ {initials}
+
+
+
{displayName}
+ {userId && (
+
+ {t('member.detail.user_id_label')}
+
+ {userId}
+
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * View component for organization member detail.
+ * @param props - Component props containing state and handlers
+ * @returns The rendered member detail view element
+ */
+export function OrganizationMemberDetailView(
+ props: OrganizationMemberDetailViewProps,
+): React.JSX.Element {
+ const {
+ styling,
+ customMessages,
+ activeTab,
+ modalState,
+ isRemovingFromOrg,
+ setActiveTab,
+ closeModal,
+ handleRemoveFromOrgConfirm,
+ } = props;
+
+ const { isDarkMode } = useTheme();
+ const { t } = useTranslator('member_management', customMessages);
+
+ const currentStyles = React.useMemo(
+ () => getComponentStyles(styling, isDarkMode),
+ [styling, isDarkMode],
+ );
+
+ if (props.memberError) {
+ return (
+
+
+
+
+ {t('member.detail.back_button')}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ setActiveTab(value as 'details' | 'roles')}
+ className={cn('gap-8', currentStyles.classes?.['OrganizationMemberDetail-tabs'])}
+ >
+
+ {t('member.detail.tabs.details')}
+ {t('member.detail.tabs.roles')}
+
+
+
+ props.openModal({ type: 'removeFromOrg' })}
+ />
+
+
+
+ props.openModal({ type: 'assignRoles' })}
+ onAssignRolesCancel={closeModal}
+ onAssignRolesSubmit={props.handleAssignRolesSubmit}
+ onRemoveRolesClick={(roles) => props.openModal({ type: 'removeRoles', roles })}
+ onRemoveRolesCancel={props.handleRemoveRolesCancel}
+ onRemoveRolesConfirm={props.handleRemoveRolesConfirm}
+ />
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Container component for organization member detail.
+ * @param props - {@link OrganizationMemberDetailProps}
+ * @returns The rendered member detail container element
+ */
+export function OrganizationMemberDetail(props: OrganizationMemberDetailProps) {
+ const {
+ userId,
+ onBack,
+ customMessages = {},
+ styling = { variables: { common: {}, light: {}, dark: {} }, classes: {} },
+ removeFromOrgAction,
+ assignRolesAction,
+ removeRolesAction,
+ } = props;
+
+ const memberDetail = useOrganizationMemberDetail({
+ userId,
+ onBack,
+ customMessages,
+ removeFromOrgAction,
+ assignRolesAction,
+ removeRolesAction,
+ });
+
+ return (
+
+
+
+ );
+}
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..51a5ef610
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/organization-member-management.tsx
@@ -0,0 +1,299 @@
+/**
+ * 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 { MemberRemoveFromOrgModal } from '@/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-remove-from-org-modal';
+import { OrganizationMemberTable } from '@/components/auth0/my-organization/shared/member-management/members/members-table/organization-member-table';
+import { OrganizationMemberAssignRolesModal } from '@/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-assign-roles-modal';
+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 { MEMBER_MANAGEMENT_PAGE_SIZE_OPTIONS } from '@/lib/constants/my-organization/member-management/member-management-constants';
+import type {
+ OrganizationMemberManagementProps,
+ OrganizationMemberManagementViewProps,
+} from '@/types/my-organization/member-management/organization-member-management-types';
+
+/**
+ * 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,
+ members,
+ invitations,
+ orgDisplayName,
+ isFetchingInvitations,
+ isFetchingMembers,
+ isFetchingAvailableRoles,
+ isCreatingInvitation,
+ isRevokingInvitation,
+ isResendingInvitation,
+ invitationPagination,
+ memberPagination,
+ invitationSortConfig,
+ memberSortConfig,
+ isAssigningRoles,
+ isRemovingFromOrg,
+ availableRoles,
+ availableProviders,
+ modalState,
+ setActiveTab,
+ openModal,
+ closeModal,
+ handleCreateSubmit,
+ handleRevokeConfirm,
+ handleRevokeResendConfirm,
+ handleCopyUrl,
+ handleSortChange,
+ handleNextPage,
+ handlePreviousPage,
+ handlePageSizeChange,
+ handleRoleFilterChange,
+ handleViewMemberDetails,
+ handleAssignRolesSubmit,
+ handleRemoveFromOrgConfirm,
+ } = props;
+
+ const selectedInvitation =
+ modalState.type === 'details' ||
+ modalState.type === 'revoke' ||
+ modalState.type === 'revokeResend'
+ ? modalState.invitation
+ : null;
+
+ const selectedMember =
+ modalState.type === 'removeFromOrg' || modalState.type === 'assignRole'
+ ? modalState.member
+ : null;
+
+ const { isDarkMode } = useTheme();
+ const { t } = useTranslator('member_management', customMessages);
+
+ const currentStyles = React.useMemo(
+ () => getComponentStyles(styling, isDarkMode),
+ [styling, isDarkMode],
+ );
+
+ const pageSizeOptions = MEMBER_MANAGEMENT_PAGE_SIZE_OPTIONS;
+
+ 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: 'assignRole', member })}
+ onRemoveFromOrg={(member) => openModal({ type: 'removeFromOrg', member })}
+ onSortChange={handleSortChange}
+ onNextPage={handleNextPage}
+ onPreviousPage={handlePreviousPage}
+ onPageSizeChange={handlePageSizeChange}
+ onRoleFilterChange={handleRoleFilterChange}
+ />
+
+
+
+ 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']}
+ />
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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,
+ viewMemberDetailsAction,
+ assignRolesAction,
+ removeFromOrgAction,
+ } = props;
+
+ const memberManagement = useOrganizationMemberManagement({
+ customMessages,
+ readOnly,
+ createInvitationAction,
+ revokeInvitationAction,
+ resendInvitationAction,
+ viewMemberDetailsAction,
+ assignRolesAction,
+ removeFromOrgAction,
+ });
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/idp-management/sso-provider-edit/__tests__/sso-provider-tab.test.tsx b/packages/react/src/components/auth0/my-organization/shared/idp-management/sso-provider-edit/__tests__/sso-provider-tab.test.tsx
index 755900c4b..1c70a2cf0 100644
--- a/packages/react/src/components/auth0/my-organization/shared/idp-management/sso-provider-edit/__tests__/sso-provider-tab.test.tsx
+++ b/packages/react/src/components/auth0/my-organization/shared/idp-management/sso-provider-edit/__tests__/sso-provider-tab.test.tsx
@@ -31,7 +31,7 @@ describe('SsoProviderTab', () => {
display_name: 'Test Provider Display',
options: {},
strategy: 'oidc', // Use a valid strategy property
- attributes: [],
+ attributes: [], // Required by IdpOidcResponse
},
onDelete: vi.fn(),
onRemove: vi.fn(),
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
new file mode 100644
index 000000000..e57a09360
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/__tests__/organization-invitation-details-modal.test.tsx
@@ -0,0 +1,467 @@
+import { screen, act, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi, describe, it, expect, afterEach } from 'vitest';
+
+import { OrganizationInvitationDetailsModal } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal';
+import { renderWithProviders, TestProvider } from '@/tests/utils';
+import {
+ createMockDetailsModalProps,
+ createMockInvitation,
+ createMockPendingInvitation,
+ createMockExpiredInvitation,
+ createMockRoles,
+ createMockProviders,
+} from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks';
+
+describe('OrganizationInvitationDetailsModal', () => {
+ const invitationUrl = 'https://example.auth0.com/invite?ticket=abc';
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('isOpen', () => {
+ describe('when is true', () => {
+ it('should render the modal', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(
+ screen.getByRole('heading', { name: 'invitation.details.title' }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('when is false', () => {
+ it('should not render the modal content', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('invitation', () => {
+ describe('when invitation is provided', () => {
+ it('should display the invitee email', () => {
+ const invitation = createMockInvitation({ invitee: { email: 'user@example.com' } });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByDisplayValue('user@example.com')).toBeInTheDocument();
+ });
+
+ it('should display the inviter name', () => {
+ const invitation = createMockInvitation({ inviter: { name: 'John Doe' } });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByDisplayValue('John Doe')).toBeInTheDocument();
+ });
+
+ it('should display created_at date', () => {
+ const invitation = createMockInvitation({
+ created_at: '2024-06-15T10:00:00.000Z',
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.details.created_at_label')).toBeInTheDocument();
+ });
+
+ it('should display expires_at date', () => {
+ const invitation = createMockInvitation({
+ expires_at: '2025-06-15T10:00:00.000Z',
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.details.expires_at_label')).toBeInTheDocument();
+ });
+ });
+
+ describe('when invitation is null', () => {
+ it('should handle null invitation gracefully', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('status badge', () => {
+ it('should display pending status for pending invitations', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.table.status_pending')).toBeInTheDocument();
+ });
+
+ it('should display expired status for expired invitations', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.table.status_expired')).toBeInTheDocument();
+ });
+ });
+
+ describe('roles', () => {
+ it('should resolve role IDs to names when availableRoles provided', () => {
+ const invitation = createMockInvitation({ roles: ['role_admin', 'role_member'] });
+ const availableRoles = createMockRoles();
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('Admin')).toBeInTheDocument();
+ expect(screen.getByText('Member')).toBeInTheDocument();
+ });
+
+ it('should show role ID as fallback when role not found in availableRoles', () => {
+ const invitation = createMockInvitation({ roles: ['role_unknown'] });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('role_unknown')).toBeInTheDocument();
+ });
+
+ it('should show dash when no roles assigned', () => {
+ const invitation = createMockInvitation({ roles: [] });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.details.roles_label')).toBeInTheDocument();
+ });
+ });
+
+ describe('invitation URL', () => {
+ it('should display invitation URL when available', () => {
+ const invitation = createMockInvitation({
+ invitation_url: invitationUrl,
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.details.invitation_url_label')).toBeInTheDocument();
+ });
+
+ it('should not display invitation URL section when no URL', () => {
+ const invitation = createMockInvitation({ invitation_url: undefined });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByText('invitation.details.invitation_url_label')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('identity provider', () => {
+ it('should display 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', () => {
+ 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 });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByText('invitation.details.provider_label')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('readOnly', () => {
+ describe('when readOnly is false', () => {
+ it('should show Revoke and Resend buttons', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'invitation.details.revoke_button' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'invitation.details.resend_button' }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('when readOnly is true', () => {
+ it('should not show Revoke and Resend buttons', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByRole('button', { name: 'invitation.details.revoke_button' }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'invitation.details.resend_button' }),
+ ).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ 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();
+ const onRevoke = vi.fn();
+ const invitation = createMockPendingInvitation();
+
+ renderWithProviders(
+ ,
+ );
+
+ const revokeButton = screen.getByRole('button', {
+ name: 'invitation.details.revoke_button',
+ });
+ await user.click(revokeButton);
+
+ expect(onRevoke).toHaveBeenCalledTimes(1);
+ expect(onRevoke).toHaveBeenCalledWith(invitation);
+ });
+
+ it('should call onResend when Resend button is clicked', async () => {
+ const user = userEvent.setup();
+ const onResend = vi.fn();
+ const invitation = createMockPendingInvitation();
+
+ renderWithProviders(
+ ,
+ );
+
+ const resendButton = screen.getByRole('button', {
+ name: 'invitation.details.resend_button',
+ });
+ await user.click(resendButton);
+
+ expect(onResend).toHaveBeenCalledTimes(1);
+ expect(onResend).toHaveBeenCalledWith(invitation);
+ });
+
+ it('should call onClose when Close button is clicked', async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const closeButton = screen.getByRole('button', {
+ name: 'invitation.details.close_button',
+ });
+ await user.click(closeButton);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('action in progress', () => {
+ it('should disable Revoke button when isRevoking is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ const revokeButton = screen.getByRole('button', {
+ name: /invitation\.details\.revoke_button/,
+ });
+ expect(revokeButton).toBeDisabled();
+ });
+
+ it('should disable Resend button when isResending is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ const resendButton = screen.getByRole('button', {
+ name: /invitation\.details\.resend_button/,
+ });
+ expect(resendButton).toBeDisabled();
+ });
+
+ it('should disable both buttons when either action is in progress', () => {
+ renderWithProviders(
+ ,
+ );
+
+ const revokeButton = screen.getByRole('button', {
+ name: /invitation\.details\.revoke_button/,
+ });
+ const resendButton = screen.getByRole('button', {
+ name: /invitation\.details\.resend_button/,
+ });
+ expect(revokeButton).toBeDisabled();
+ expect(resendButton).toBeDisabled();
+ });
+ });
+
+ describe('className', () => {
+ it('should apply custom class to modal', () => {
+ const customClass = 'custom-details-class';
+
+ renderWithProviders(
+ ,
+ );
+
+ const modalContent = document.querySelector('[data-slot="dialog-content"]');
+ expect(modalContent).toHaveClass(customClass);
+ });
+ });
+});
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
new file mode 100644
index 000000000..7b51c51f7
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal.tsx
@@ -0,0 +1,273 @@
+/**
+ * Organization invitation details modal component.
+ * @module organization-invitation-details-modal
+ */
+
+import { Link, Copy, Check } from 'lucide-react';
+import * as React from 'react';
+
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
+import { Spinner } from '@/components/ui/spinner';
+import { TextField } from '@/components/ui/text-field';
+import { TextFieldGroup } from '@/components/ui/text-field-group';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import { getInvitationStatus } from '@/lib/utils/my-organization/member-management/member-management-utils';
+import type {
+ InvitationStatus,
+ OrganizationInvitationDetailsModalProps,
+} from '@/types/my-organization/member-management/organization-invitation-table-types';
+
+/**
+ * Returns the badge variant for a given invitation status.
+ * @param status - The invitation status.
+ * @returns The badge variant string.
+ */
+function getStatusBadgeVariant(status: InvitationStatus): 'warning' | 'destructive' {
+ return status === 'pending' ? 'warning' : 'destructive';
+}
+
+/**
+ * Modal for viewing invitation details with revoke and resend actions.
+ * @param props - The component props.
+ * @param props.invitation - The invitation to display.
+ * @param props.isOpen - Whether the modal is open.
+ * @param props.isRevoking - Whether a revoke action is in progress.
+ * @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.readOnly - Whether in read-only mode.
+ * @param props.onClose - Callback when modal is closed.
+ * @param props.onCopyUrl - Callback when copy URL is clicked.
+ * @param props.onRevoke - Callback when revoke is clicked.
+ * @param props.onResend - Callback when revoke and resend is clicked.
+ * @param props.className - Optional CSS class name.
+ * @returns The modal component.
+ */
+export function OrganizationInvitationDetailsModal({
+ invitation,
+ isOpen,
+ isRevoking = false,
+ isResending = false,
+ customMessages = {},
+ availableRoles = [],
+ availableProviders = [],
+ readOnly = false,
+ onClose,
+ onCopyUrl,
+ onRevoke,
+ onResend,
+ className,
+}: OrganizationInvitationDetailsModalProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ const status = invitation ? getInvitationStatus(invitation) : 'pending';
+ const isPending = status === 'pending';
+ const isActionInProgress = isRevoking || isResending;
+
+ const roleNames = React.useMemo(() => {
+ if (!invitation?.roles || invitation.roles.length === 0) return [];
+ return invitation.roles
+ .map((roleId) => {
+ const role = availableRoles.find((r) => r.id === roleId);
+ return role?.name ?? roleId;
+ })
+ .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 [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]);
+
+ const handleRevoke = React.useCallback(() => {
+ if (invitation) {
+ onRevoke?.(invitation);
+ }
+ }, [invitation, onRevoke]);
+
+ const handleResend = React.useCallback(() => {
+ if (invitation) {
+ onResend?.(invitation);
+ }
+ }, [invitation, onResend]);
+
+ return (
+
+
+
+
+ {t('invitation.details.title')}
+
+ {isPending
+ ? t('invitation.table.status_pending')
+ : t('invitation.table.status_expired')}
+
+
+ {t('invitation.details.title')}
+
+
+
+ {/* Email */}
+
+
+ {t('invitation.details.email_label')}
+
+
+
+
+ {/* Created At */}
+
+
+ {t('invitation.details.created_at_label')}
+
+
+
+
+ {/* Expires At */}
+
+
+ {t('invitation.details.expires_at_label')}
+
+
+
+
+ {/* Roles */}
+
+
+ {t('invitation.details.roles_label')}
+
+ {roleNames.length > 0 ? (
+ ({ label: name, value: name }))}
+ summarizeChips={false}
+ disabled
+ readOnly
+ />
+ ) : (
+
+ )}
+
+
+ {/* Invitation URL */}
+ {invitation?.invitation_url && (
+
+
+ {t('invitation.details.invitation_url_label')}
+
+ }
+ endAdornment={
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ }
+ />
+
+ )}
+
+ {/* Revoke / Resend Actions (inline, below invitation URL) */}
+ {!readOnly && (
+
+
+ {isResending ? : null}
+ {t('invitation.details.resend_button')}
+
+
+ {isRevoking ? : null}
+ {t('invitation.details.revoke_button')}
+
+
+ )}
+
+ {/* Invited By */}
+
+
+ {t('invitation.details.invited_by_label')}
+
+
+
+
+ {/* Identity Provider */}
+ {providerName && (
+
+
+ {t('invitation.details.provider_label')}
+
+
+
+ )}
+
+
+
+ {t('invitation.details.close_button')}
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/__tests__/organization-invitation-revoke-modal.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/__tests__/organization-invitation-revoke-modal.test.tsx
new file mode 100644
index 000000000..9332fcb7b
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/__tests__/organization-invitation-revoke-modal.test.tsx
@@ -0,0 +1,246 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi, describe, it, expect, afterEach } from 'vitest';
+
+import { OrganizationInvitationRevokeModal } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal';
+import { renderWithProviders } from '@/tests/utils';
+import {
+ createMockRevokeModalProps,
+ createMockPendingInvitation,
+} from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks';
+
+describe('OrganizationInvitationRevokeModal', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('isOpen', () => {
+ describe('when is true', () => {
+ it('should render the modal', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+ });
+
+ describe('when is false', () => {
+ it('should not render the modal content', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('revoke mode', () => {
+ describe('when isRevokeAndResend is false', () => {
+ it('should render revoke-specific title', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.revoke.title')).toBeInTheDocument();
+ });
+
+ it('should render revoke-specific description', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.revoke.description')).toBeInTheDocument();
+ });
+
+ it('should render revoke-specific button text', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'invitation.revoke.confirm_button' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'invitation.revoke.cancel_button' }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('when isRevokeAndResend is true', () => {
+ it('should render revoke-and-resend title', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.revoke_resend.title')).toBeInTheDocument();
+ });
+
+ it('should render revoke-and-resend description', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.revoke_resend.description')).toBeInTheDocument();
+ });
+
+ it('should render revoke-and-resend button text', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'invitation.revoke_resend.confirm_button' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'invitation.revoke_resend.cancel_button' }),
+ ).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('isLoading', () => {
+ describe('when is true', () => {
+ it('should disable confirm button', () => {
+ renderWithProviders(
+ ,
+ );
+
+ const confirmButton = screen.getByRole('button', {
+ name: /invitation\.revoke\.confirm_button/,
+ });
+ expect(confirmButton).toBeDisabled();
+ });
+
+ it('should disable cancel button', () => {
+ renderWithProviders(
+ ,
+ );
+
+ const cancelButton = screen.getByRole('button', {
+ name: 'invitation.revoke.cancel_button',
+ });
+ expect(cancelButton).toBeDisabled();
+ });
+ });
+
+ describe('when is false', () => {
+ it('should enable confirm button', () => {
+ renderWithProviders(
+ ,
+ );
+
+ const confirmButton = screen.getByRole('button', {
+ name: 'invitation.revoke.confirm_button',
+ });
+ expect(confirmButton).toBeEnabled();
+ });
+ });
+ });
+
+ describe('onConfirm', () => {
+ it('should call onConfirm with invitation when confirm button is clicked', async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ const invitation = createMockPendingInvitation();
+
+ renderWithProviders(
+ ,
+ );
+
+ const confirmButton = screen.getByRole('button', {
+ name: 'invitation.revoke.confirm_button',
+ });
+ await user.click(confirmButton);
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ expect(onConfirm).toHaveBeenCalledWith(invitation);
+ });
+
+ it('should not call onConfirm when invitation is null', async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const confirmButton = screen.getByRole('button', {
+ name: 'invitation.revoke.confirm_button',
+ });
+ await user.click(confirmButton);
+
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('onClose', () => {
+ it('should call onClose when cancel button is clicked', async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const cancelButton = screen.getByRole('button', {
+ name: 'invitation.revoke.cancel_button',
+ });
+ await user.click(cancelButton);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('className', () => {
+ it('should apply custom class to modal', () => {
+ const customClass = 'custom-revoke-class';
+
+ renderWithProviders(
+ ,
+ );
+
+ const modalContent = document.querySelector('[data-slot="dialog-content"]');
+ expect(modalContent).toHaveClass(customClass);
+ });
+ });
+
+ describe('invitation', () => {
+ describe('when invitation is null', () => {
+ it('should handle null invitation gracefully', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+ });
+ });
+});
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
new file mode 100644
index 000000000..e187145fb
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal.tsx
@@ -0,0 +1,82 @@
+/**
+ * Organization invitation revoke modal component.
+ * @module organization-invitation-revoke-modal
+ */
+
+import * as React from 'react';
+
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+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';
+
+/**
+ * Modal for confirming invitation revocation or revoke and resend.
+ * @param props - The component props.
+ * @param props.invitation - The invitation to revoke.
+ * @param props.isOpen - Whether the modal is open.
+ * @param props.isLoading - Whether the action is in progress.
+ * @param props.isRevokeAndResend - Whether this is a revoke and resend action.
+ * @param props.customMessages - Custom translation messages.
+ * @param props.onClose - Callback when modal is closed.
+ * @param props.onConfirm - Callback when action is confirmed.
+ * @param props.className - Optional CSS class name.
+ * @returns The modal component.
+ */
+export function OrganizationInvitationRevokeModal({
+ invitation,
+ isOpen,
+ isLoading = false,
+ isRevokeAndResend = false,
+ customMessages = {},
+ onClose,
+ onConfirm,
+ className,
+}: OrganizationInvitationRevokeModalProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ const namespace = isRevokeAndResend ? 'invitation.revoke_resend' : 'invitation.revoke';
+
+ const handleConfirm = React.useCallback(() => {
+ if (invitation) {
+ onConfirm(invitation);
+ }
+ }, [invitation, onConfirm]);
+
+ return (
+
+
+
+ {t(`${namespace}.title`)}
+
+
+ <>
+ {t.trans(`${namespace}.description`, {
+ components: {
+ bold: (children: string) => {children} ,
+ },
+ vars: { email: invitation?.invitee?.email ?? '' },
+ })}
+ >
+
+
+
+ {t(`${namespace}.cancel_button`)}
+
+
+ {isLoading ? : null}
+ {t(`${namespace}.confirm_button`)}
+
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/__tests__/organization-invitation-table-actions-column.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/__tests__/organization-invitation-table-actions-column.test.tsx
new file mode 100644
index 000000000..ee819768e
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/__tests__/organization-invitation-table-actions-column.test.tsx
@@ -0,0 +1,321 @@
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { OrganizationInvitationTableActionsColumn } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table-actions-column';
+import { renderWithProviders } from '@/tests/utils';
+import {
+ createMockActionsColumnProps,
+ createMockPendingInvitation,
+ createMockExpiredInvitation,
+} from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks';
+
+describe('OrganizationInvitationTableActionsColumn', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Rendering and Basic Structure', () => {
+ it('should render dropdown trigger button', () => {
+ const props = createMockActionsColumnProps();
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ expect(trigger).toBeInTheDocument();
+ expect(trigger).toHaveClass('h-8', 'w-8');
+ });
+
+ it('should have proper accessibility attributes', () => {
+ const props = createMockActionsColumnProps();
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ expect(trigger).toHaveAttribute('type', 'button');
+ });
+ });
+
+ describe('Dropdown Menu Interactions', () => {
+ it('should open dropdown menu when trigger button is clicked', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps();
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.getByRole('menuitem', { name: 'invitation.actions.view_details' }),
+ ).toBeInTheDocument();
+ });
+
+ it('should close dropdown menu when user presses Escape key', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps();
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.getByRole('menuitem', { name: 'invitation.actions.view_details' }),
+ ).toBeInTheDocument();
+
+ await user.keyboard('{Escape}');
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('menuitem', { name: 'invitation.actions.view_details' }),
+ ).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Invitation Status: Pending', () => {
+ it('should show View Details action', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps({
+ invitation: createMockPendingInvitation(),
+ });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.getByRole('menuitem', { name: 'invitation.actions.view_details' }),
+ ).toBeInTheDocument();
+ });
+
+ it('should show Copy URL action when invitation has URL', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps({
+ invitation: createMockPendingInvitation({
+ invitation_url: 'https://example.com/invite?ticket=abc',
+ }),
+ });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.getByRole('menuitem', { name: 'invitation.actions.copy_url' }),
+ ).toBeInTheDocument();
+ });
+
+ it('should not show Copy URL action when invitation has no URL', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps({
+ invitation: createMockPendingInvitation({ invitation_url: undefined }),
+ });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.queryByRole('menuitem', { name: 'invitation.actions.copy_url' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should show Revoke & Resend action when not readOnly', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps({ readOnly: false });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.getByRole('menuitem', { name: 'invitation.actions.revoke_and_resend' }),
+ ).toBeInTheDocument();
+ });
+
+ it('should show Revoke action when not readOnly', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps({ readOnly: false });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.getByRole('menuitem', { name: 'invitation.actions.revoke' }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('Invitation Status: Expired', () => {
+ it('should show View Details action', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps({
+ invitation: createMockExpiredInvitation(),
+ });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.getByRole('menuitem', { name: 'invitation.actions.view_details' }),
+ ).toBeInTheDocument();
+ });
+
+ it('should not show Copy URL action for expired invitations', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps({
+ invitation: createMockExpiredInvitation(),
+ });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.queryByRole('menuitem', { name: 'invitation.actions.copy_url' }),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Read-Only Mode', () => {
+ it('should not show Revoke & Resend action when readOnly', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps({ readOnly: true });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.queryByRole('menuitem', { name: 'invitation.actions.revoke_and_resend' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should not show Revoke action when readOnly', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps({ readOnly: true });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.queryByRole('menuitem', { name: 'invitation.actions.revoke' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should still show View Details when readOnly', async () => {
+ const user = userEvent.setup();
+ const props = createMockActionsColumnProps({ readOnly: true });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ expect(
+ screen.getByRole('menuitem', { name: 'invitation.actions.view_details' }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('Callback Invocations', () => {
+ it('should call onViewDetails when View Details is clicked', async () => {
+ const user = userEvent.setup();
+ const onViewDetails = vi.fn();
+ const invitation = createMockPendingInvitation();
+ const props = createMockActionsColumnProps({ invitation, onViewDetails });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ const menuItem = screen.getByRole('menuitem', {
+ name: 'invitation.actions.view_details',
+ });
+ await user.click(menuItem);
+
+ expect(onViewDetails).toHaveBeenCalledTimes(1);
+ expect(onViewDetails).toHaveBeenCalledWith(invitation);
+ });
+
+ it('should call onCopyUrl when Copy URL is clicked', async () => {
+ const user = userEvent.setup();
+ const onCopyUrl = vi.fn();
+ const invitation = createMockPendingInvitation({
+ invitation_url: 'https://example.com/invite',
+ });
+ const props = createMockActionsColumnProps({ invitation, onCopyUrl });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ const menuItem = screen.getByRole('menuitem', {
+ name: 'invitation.actions.copy_url',
+ });
+ await user.click(menuItem);
+
+ expect(onCopyUrl).toHaveBeenCalledTimes(1);
+ expect(onCopyUrl).toHaveBeenCalledWith(invitation);
+ });
+
+ it('should call onRevokeAndResend when Revoke & Resend is clicked', async () => {
+ const user = userEvent.setup();
+ const onRevokeAndResend = vi.fn();
+ const invitation = createMockPendingInvitation();
+ const props = createMockActionsColumnProps({ invitation, onRevokeAndResend });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ const menuItem = screen.getByRole('menuitem', {
+ name: 'invitation.actions.revoke_and_resend',
+ });
+ await user.click(menuItem);
+
+ expect(onRevokeAndResend).toHaveBeenCalledTimes(1);
+ expect(onRevokeAndResend).toHaveBeenCalledWith(invitation);
+ });
+
+ it('should call onRevoke when Revoke is clicked', async () => {
+ const user = userEvent.setup();
+ const onRevoke = vi.fn();
+ const invitation = createMockPendingInvitation();
+ const props = createMockActionsColumnProps({ invitation, onRevoke });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ const menuItem = screen.getByRole('menuitem', {
+ name: 'invitation.actions.revoke',
+ });
+ await user.click(menuItem);
+
+ expect(onRevoke).toHaveBeenCalledTimes(1);
+ expect(onRevoke).toHaveBeenCalledWith(invitation);
+ });
+ });
+
+ describe('Custom Messages', () => {
+ it('should accept custom messages prop without error', async () => {
+ const user = userEvent.setup();
+ const customMessages = {
+ actions: {
+ view_details: 'Custom View Details',
+ },
+ };
+ const props = createMockActionsColumnProps({ customMessages });
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button');
+ await user.click(trigger);
+
+ // The mock translator returns keys, so verify the menu item renders
+ expect(
+ screen.getByRole('menuitem', { name: 'invitation.actions.view_details' }),
+ ).toBeInTheDocument();
+ });
+ });
+});
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
new file mode 100644
index 000000000..402bf504c
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table-actions-column.tsx
@@ -0,0 +1,123 @@
+/**
+ * Organization invitation table row actions dropdown.
+ * @module organization-invitation-table-actions-column
+ * @internal
+ */
+
+import { MoreHorizontal, Eye, Copy, RefreshCcw, Trash2 } from 'lucide-react';
+import * as React from 'react';
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ 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';
+
+/**
+ * OrganizationInvitationTableActionsColumn Component
+ * Handles the actions column for Invitation table with dropdown menu.
+ * @param props - Component props.
+ * @param props.invitation - The invitation to show actions for.
+ * @param props.customMessages - Custom translation messages to override defaults.
+ * @param props.readOnly - Whether the component is in read-only mode.
+ * @param props.onViewDetails - Callback fired when view details action is triggered.
+ * @param props.onCopyUrl - Callback fired when copy URL action is triggered.
+ * @param props.onRevokeAndResend - Callback fired when revoke and resend action is triggered.
+ * @param props.onRevoke - Callback fired when revoke action is triggered.
+ * @returns JSX element.
+ */
+export function OrganizationInvitationTableActionsColumn({
+ invitation,
+ customMessages = {},
+ readOnly = false,
+ onViewDetails,
+ onCopyUrl,
+ onRevokeAndResend,
+ onRevoke,
+}: OrganizationInvitationTableActionsColumnProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+ 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(() => {
+ onRevokeAndResend?.(invitation);
+ }, [invitation, onRevokeAndResend]);
+
+ const handleRevoke = React.useCallback(() => {
+ onRevoke?.(invitation);
+ }, [invitation, onRevoke]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {/* View Details - always available */}
+
+
+ {t('invitation.actions.view_details')}
+
+
+ {isPending && invitation.invitation_url && (
+
+
+ {t('invitation.actions.copy_url')}
+
+ )}
+
+ {!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
new file mode 100644
index 000000000..cfd080229
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table.tsx
@@ -0,0 +1,192 @@
+/**
+ * Organization invitation table component.
+ * @module organization-invitation-table
+ * @internal
+ */
+
+import type { MemberInvitation } from '@auth0/universal-components-core';
+import * as React from 'react';
+
+import { OrganizationInvitationTableActionsColumn } from './organization-invitation-table-actions-column';
+
+import { SearchFilter } from '@/components/auth0/my-organization/shared/member-management/shared/search-filter/search-filter';
+import { DataPagination } from '@/components/auth0/shared/data-pagination';
+import { DataTable, type Column } from '@/components/auth0/shared/data-table';
+import { Badge } from '@/components/ui/badge';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import { cn } from '@/lib/utils';
+import { getInvitationStatus } from '@/lib/utils/my-organization/member-management/member-management-utils';
+import type { OrganizationInvitationTableProps } from '@/types/my-organization/member-management/organization-invitation-table-types';
+
+/**
+ * Organization invitation table component.
+ * Displays invitations with search, filtering, and pagination.
+ * @param props - The component props.
+ * @param props.invitations - The list of invitations to display.
+ * @param props.loading - Whether the table is loading.
+ * @param props.customMessages - Custom translation messages.
+ * @param props.pagination - Pagination state.
+ * @param props.pageSizeOptions - Options for page size selection.
+ * @param props.filters - Current filter state.
+ * @param props.availableRoles - Available roles for filtering.
+ * @param props.readOnly - Whether the component is in read-only mode.
+ * @param props.onView - Callback when viewing invitation details.
+ * @param props.onCopyUrl - Callback when copying invitation URL.
+ * @param props.onRevokeAndResend - Callback when revoking and resending invitation.
+ * @param props.onRevoke - Callback when revoking invitation.
+ * @param props.onPageChange - Callback when page changes.
+ * @param props.onPageSizeChange - Callback when page size changes.
+ * @param props.onRoleFilterChange - Callback when role filter changes.
+ * @param props.className - Optional CSS class name.
+ * @returns The invitation table component.
+ */
+export function OrganizationInvitationTable({
+ invitations,
+ loading = false,
+ customMessages = {},
+ pagination,
+ pageSizeOptions,
+ filters,
+ availableRoles,
+ readOnly = false,
+ sortConfig,
+ onSortChange,
+ onView,
+ onCopyUrl,
+ onRevokeAndResend,
+ onRevoke,
+ onNextPage,
+ onPreviousPage,
+ onPageSizeChange,
+ onRoleFilterChange,
+ className,
+}: OrganizationInvitationTableProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ const renderDate = (_invitation: MemberInvitation, value: string | number | Date) => (
+
+ {new Date(value).toLocaleString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ })}
+
+ );
+
+ const columns: Column[] = React.useMemo(
+ () => [
+ {
+ type: 'text',
+ accessorKey: 'invitee',
+ title: t('invitation.table.columns.email'),
+ width: '25%',
+ enableSorting: false,
+ render: (invitation) => (
+ {invitation.invitee?.email}
+ ),
+ },
+ {
+ type: 'text',
+ accessorKey: 'organization_id',
+ title: t('invitation.table.columns.status'),
+ width: '10%',
+ enableSorting: false,
+ render: (invitation) => {
+ const status = getInvitationStatus(invitation);
+ return (
+
+ {status === 'pending'
+ ? t('invitation.table.status_pending')
+ : t('invitation.table.status_expired')}
+
+ );
+ },
+ },
+ {
+ type: 'date',
+ accessorKey: 'created_at',
+ title: t('invitation.table.columns.created_at'),
+ enableSorting: true,
+ format: 'medium',
+ render: renderDate,
+ },
+ {
+ type: 'date',
+ accessorKey: 'expires_at',
+ title: t('invitation.table.columns.expires_at'),
+ enableSorting: false,
+ format: 'medium',
+ render: renderDate,
+ },
+ {
+ type: 'text',
+ accessorKey: 'inviter',
+ title: t('invitation.table.columns.inviter'),
+ enableSorting: false,
+ render: (invitation) => (
+ {invitation.inviter?.name ?? '-'}
+ ),
+ },
+ {
+ type: 'actions',
+ title: '',
+ enableSorting: false,
+ render: (invitation) => (
+
+ ),
+ },
+ ],
+ [t, customMessages, readOnly, onView, onCopyUrl, onRevokeAndResend, onRevoke],
+ );
+
+ return (
+
+
+
+
+
+ {!loading && invitations.length > 0 && (
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/__tests__/member-detail-danger-zone.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/__tests__/member-detail-danger-zone.test.tsx
new file mode 100644
index 000000000..6b17c6ae7
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/__tests__/member-detail-danger-zone.test.tsx
@@ -0,0 +1,86 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, afterEach, vi } from 'vitest';
+
+import { MemberDetailDangerZone } from '@/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-detail-danger-zone';
+import { renderWithProviders } from '@/tests/utils';
+import { createMockDangerZoneProps } from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+
+describe('MemberDetailDangerZone', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('should render the remove from org card', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText('member.detail.actions.remove_from_org.title')).toBeInTheDocument();
+ });
+
+ it('should render the remove from org button', () => {
+ renderWithProviders( );
+
+ expect(
+ screen.getByRole('button', { name: 'member.detail.actions.remove_from_org.button' }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('readOnly', () => {
+ it('should disable the button when readOnly is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'member.detail.actions.remove_from_org.button' }),
+ ).toBeDisabled();
+ });
+
+ it('should enable the button when readOnly is false', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'member.detail.actions.remove_from_org.button' }),
+ ).toBeEnabled();
+ });
+ });
+
+ describe('loading states', () => {
+ it('should disable button when isRemovingFromOrg is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Loading...' })).toBeDisabled();
+ });
+
+ it('should show spinner when isRemovingFromOrg is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Loading...' })).toBeInTheDocument();
+ });
+ });
+
+ describe('callbacks', () => {
+ it('should call onRemoveFromOrgClick when remove button is clicked', async () => {
+ const user = userEvent.setup();
+ const onRemoveFromOrgClick = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('button', { name: 'member.detail.actions.remove_from_org.button' }),
+ );
+
+ expect(onRemoveFromOrgClick).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/__tests__/member-remove-from-org-modal.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/__tests__/member-remove-from-org-modal.test.tsx
new file mode 100644
index 000000000..2ee712075
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/__tests__/member-remove-from-org-modal.test.tsx
@@ -0,0 +1,128 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, afterEach, vi } from 'vitest';
+
+import { MemberRemoveFromOrgModal } from '@/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-remove-from-org-modal';
+import { renderWithProviders } from '@/tests/utils';
+import { createMockRemoveFromOrgModalProps } from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+
+describe('MemberRemoveFromOrgModal', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('isOpen', () => {
+ it('should render the modal when isOpen is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('should not render the modal when isOpen is false', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('content', () => {
+ it('should display the confirm title', () => {
+ renderWithProviders( );
+
+ expect(
+ screen.getByText('member.detail.actions.remove_from_org.modal.title'),
+ ).toBeInTheDocument();
+ });
+
+ it('should display the confirm description', () => {
+ renderWithProviders( );
+
+ expect(
+ screen.getByText('member.detail.actions.remove_from_org.modal.description'),
+ ).toBeInTheDocument();
+ });
+
+ it('should render confirm and cancel buttons', () => {
+ renderWithProviders( );
+
+ expect(
+ screen.getByRole('button', {
+ name: 'member.detail.actions.remove_from_org.modal.confirm_button',
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', {
+ name: 'member.detail.actions.remove_from_org.modal.cancel_button',
+ }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('isLoading', () => {
+ it('should disable both buttons when isLoading is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', {
+ name: 'member.detail.actions.remove_from_org.modal.cancel_button',
+ }),
+ ).toBeDisabled();
+ });
+
+ it('should enable buttons when isLoading is false', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', {
+ name: 'member.detail.actions.remove_from_org.modal.cancel_button',
+ }),
+ ).toBeEnabled();
+ });
+ });
+
+ describe('onConfirm', () => {
+ it('should call onConfirm when confirm button is clicked', async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('button', {
+ name: 'member.detail.actions.remove_from_org.modal.confirm_button',
+ }),
+ );
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('onClose', () => {
+ it('should call onClose when cancel button is clicked', async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('button', {
+ name: 'member.detail.actions.remove_from_org.modal.cancel_button',
+ }),
+ );
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-detail-danger-zone.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-detail-danger-zone.tsx
new file mode 100644
index 000000000..a4f52cc0a
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-detail-danger-zone.tsx
@@ -0,0 +1,81 @@
+/**
+ * Member detail danger zone component — remove from org and delete member actions.
+ * @module member-detail-danger-zone
+ * @internal
+ */
+
+import * as React from 'react';
+
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { Spinner } from '@/components/ui/spinner';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type {
+ MemberDetailDangerCardProps,
+ MemberDetailDangerZoneProps,
+} from '@/types/my-organization/member-management/organization-member-detail-types';
+
+/**
+ * Renders a single danger zone action card with a title, description, and destructive button.
+ * @param props - Component props
+ * @param props.title - Card title text
+ * @param props.description - Card description text
+ * @param props.buttonLabel - Label for the action button
+ * @param props.isLoading - Whether the action is in progress
+ * @param props.disabled - Whether the button is disabled
+ * @param props.onClick - Click handler for the action button
+ * @returns The rendered danger card element
+ */
+function DangerCard({
+ title,
+ description,
+ buttonLabel,
+ isLoading,
+ disabled,
+ onClick,
+}: MemberDetailDangerCardProps): React.JSX.Element {
+ return (
+
+
+ {title}
+ {description}
+
+
+ {isLoading ? : buttonLabel}
+
+
+ );
+}
+
+/**
+ * Renders the danger zone section with remove from org and delete member actions.
+ * @param props - Component props
+ * @returns The rendered danger zone section element
+ */
+export function MemberDetailDangerZone({
+ readOnly = false,
+ isRemovingFromOrg = false,
+ customMessages,
+ onRemoveFromOrgClick,
+}: MemberDetailDangerZoneProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-remove-from-org-modal.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-remove-from-org-modal.tsx
new file mode 100644
index 000000000..74f9c82a7
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-remove-from-org-modal.tsx
@@ -0,0 +1,76 @@
+/**
+ * Confirmation modal for removing a member from the organization.
+ * @module member-remove-from-org-modal
+ * @internal
+ */
+
+import * as React from 'react';
+
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Spinner } from '@/components/ui/spinner';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type { MemberRemoveFromOrgModalProps } from '@/types/my-organization/member-management/organization-member-detail-types';
+
+/**
+ * Renders the remove from organization confirmation dialog.
+ * @param props - Component props
+ * @returns The rendered confirmation dialog element
+ */
+export function MemberRemoveFromOrgModal({
+ isOpen,
+ isLoading = false,
+ memberName,
+ memberUserId,
+ orgName,
+ customMessages,
+ onClose,
+ onConfirm,
+}: MemberRemoveFromOrgModalProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ const handleSubmit = React.useCallback(() => {
+ onConfirm(memberUserId, memberName, orgName);
+ }, [onConfirm, memberUserId, memberName, orgName]);
+
+ return (
+ !open && onClose()}>
+
+
+
+ {t('member.detail.actions.remove_from_org.modal.title', { orgName })}
+
+
+ <>
+ {t.trans('member.detail.actions.remove_from_org.modal.description', {
+ components: {
+ bold: (children: string) => {children} ,
+ },
+ vars: { memberName },
+ })}
+ >
+
+
+
+
+ {t('member.detail.actions.remove_from_org.modal.cancel_button')}
+
+
+ {isLoading ? (
+
+ ) : (
+ t('member.detail.actions.remove_from_org.modal.confirm_button')
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/__tests__/organization-member-table-actions-column.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/__tests__/organization-member-table-actions-column.test.tsx
new file mode 100644
index 000000000..814de6b4a
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/__tests__/organization-member-table-actions-column.test.tsx
@@ -0,0 +1,122 @@
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { OrganizationMemberTableActionsColumn } from '@/components/auth0/my-organization/shared/member-management/members/members-table/organization-member-table-actions-column';
+import { renderWithProviders } from '@/tests/utils';
+import {
+ createMockMember,
+ createMockMemberActionsColumnProps,
+} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+
+describe('OrganizationMemberTableActionsColumn', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('Rendering and Basic Structure', () => {
+ it('should render dropdown trigger button', () => {
+ const props = createMockMemberActionsColumnProps();
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button', { name: 'member.actions.menu_label' });
+ expect(trigger).toBeInTheDocument();
+ expect(trigger).toHaveClass('h-8', 'w-8');
+ });
+
+ it('should have proper accessibility attributes', () => {
+ const props = createMockMemberActionsColumnProps();
+ renderWithProviders( );
+
+ const trigger = screen.getByRole('button', { name: 'member.actions.menu_label' });
+ expect(trigger).toHaveAttribute('type', 'button');
+ });
+ });
+
+ describe('Dropdown Menu Interactions', () => {
+ it('should open dropdown menu when trigger button is clicked', async () => {
+ const user = userEvent.setup();
+ const props = createMockMemberActionsColumnProps();
+ renderWithProviders( );
+
+ await user.click(screen.getByRole('button', { name: 'member.actions.menu_label' }));
+
+ expect(
+ screen.getByRole('menuitem', { name: 'member.actions.view_details' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('menuitem', { name: 'member.actions.assign_role' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('menuitem', { name: 'member.actions.remove_from_org' }),
+ ).toBeInTheDocument();
+ });
+
+ it('should close dropdown menu when user presses Escape key', async () => {
+ const user = userEvent.setup();
+ const props = createMockMemberActionsColumnProps();
+ renderWithProviders( );
+
+ await user.click(screen.getByRole('button', { name: 'member.actions.menu_label' }));
+ expect(
+ screen.getByRole('menuitem', { name: 'member.actions.assign_role' }),
+ ).toBeInTheDocument();
+
+ await user.keyboard('{Escape}');
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('menuitem', { name: 'member.actions.assign_role' }),
+ ).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Actions', () => {
+ it('should call onViewDetails when View Details is clicked', async () => {
+ const user = userEvent.setup();
+ const onViewDetails = vi.fn();
+ const member = createMockMember({ user_id: 'usr_abc' });
+ const props = createMockMemberActionsColumnProps({ member, onViewDetails });
+ renderWithProviders( );
+
+ await user.click(screen.getByRole('button', { name: 'member.actions.menu_label' }));
+ await user.click(screen.getByRole('menuitem', { name: 'member.actions.view_details' }));
+
+ expect(onViewDetails).toHaveBeenCalledTimes(1);
+ expect(onViewDetails).toHaveBeenCalledWith('usr_abc');
+ });
+
+ it('should call onAssignRole when Assign Role is clicked', async () => {
+ const user = userEvent.setup();
+ const onAssignRole = vi.fn();
+ const member = createMockMember();
+ const props = createMockMemberActionsColumnProps({ member, onAssignRole });
+ renderWithProviders( );
+
+ await user.click(screen.getByRole('button', { name: 'member.actions.menu_label' }));
+ await user.click(screen.getByRole('menuitem', { name: 'member.actions.assign_role' }));
+
+ expect(onAssignRole).toHaveBeenCalledTimes(1);
+ expect(onAssignRole).toHaveBeenCalledWith(member);
+ });
+
+ it('should call onRemoveFromOrg when Remove from Org is clicked', async () => {
+ const user = userEvent.setup();
+ const onRemoveFromOrg = vi.fn();
+ const member = createMockMember();
+ const props = createMockMemberActionsColumnProps({ member, onRemoveFromOrg });
+ renderWithProviders( );
+
+ await user.click(screen.getByRole('button', { name: 'member.actions.menu_label' }));
+ await user.click(screen.getByRole('menuitem', { name: 'member.actions.remove_from_org' }));
+
+ expect(onRemoveFromOrg).toHaveBeenCalledTimes(1);
+ expect(onRemoveFromOrg).toHaveBeenCalledWith(member);
+ });
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/__tests__/organization-member-table.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/__tests__/organization-member-table.test.tsx
new file mode 100644
index 000000000..f941f381e
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/__tests__/organization-member-table.test.tsx
@@ -0,0 +1,271 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { OrganizationMemberTable } from '@/components/auth0/my-organization/shared/member-management/members/members-table/organization-member-table';
+import { renderWithProviders } from '@/tests/utils';
+import {
+ createMockMember,
+ createMockMemberTableProps,
+} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+
+describe('OrganizationMemberTable', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('should render member details, roles, and relative last login text', () => {
+ vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-05-15T12:00:00.000Z').getTime());
+
+ const props = createMockMemberTableProps({
+ members: [
+ createMockMember({
+ user_id: 'usr_1',
+ given_name: 'Ada',
+ family_name: 'Lovelace',
+ email: 'ada@example.com',
+ name: '',
+ last_login: '2026-05-13T12:00:00.000Z',
+ roles: [],
+ }),
+ ],
+ pagination: {
+ pageSize: 10,
+ currentPage: 1,
+ totalItems: 0,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ },
+ });
+
+ renderWithProviders( );
+ expect(screen.getByText('member.table.columns.last_login')).toBeInTheDocument();
+ });
+
+ it('should render fallback values when member roles and last login are missing', () => {
+ const props = createMockMemberTableProps({
+ members: [
+ createMockMember({
+ user_id: 'usr_2',
+ given_name: undefined,
+ family_name: undefined,
+ name: '',
+ email: undefined,
+ last_login: undefined,
+ roles: [],
+ }),
+ ],
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getAllByText('-').length).toBeGreaterThan(0);
+ });
+
+ it('should render the empty state when there are no members', () => {
+ const props = createMockMemberTableProps({
+ members: [],
+ pagination: {
+ pageSize: 10,
+ currentPage: 1,
+ totalItems: 0,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ },
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByText('member.table.empty_message')).toBeInTheDocument();
+ expect(screen.queryByLabelText('Go to next page')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('renderName', () => {
+ it('should render display name, email, and two-letter initials when given and family names are present', () => {
+ const props = createMockMemberTableProps({
+ members: [
+ createMockMember({
+ user_id: 'usr_name_1',
+ given_name: 'Ada',
+ family_name: 'Lovelace',
+ name: 'ignored-name',
+ email: 'ada@example.com',
+ roles: [],
+ last_login: undefined,
+ }),
+ ],
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
+ expect(screen.getByText('ada@example.com')).toBeInTheDocument();
+ expect(screen.getByText('AL')).toBeInTheDocument();
+ });
+
+ it('should fall back to member.name when given/family names are missing', () => {
+ const props = createMockMemberTableProps({
+ members: [
+ createMockMember({
+ user_id: 'usr_name_2',
+ given_name: undefined,
+ family_name: undefined,
+ name: 'Grace Hopper',
+ email: 'grace@example.com',
+ roles: [],
+ last_login: undefined,
+ }),
+ ],
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByText('Grace Hopper')).toBeInTheDocument();
+ expect(screen.getByText('GH')).toBeInTheDocument();
+ });
+ });
+
+ describe('renderRoles', () => {
+ it('should render "-" when member has no roles', () => {
+ const props = createMockMemberTableProps({
+ members: [
+ createMockMember({
+ user_id: 'usr_roles_1',
+ roles: [],
+ last_login: undefined,
+ }),
+ ],
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getAllByText('-').length).toBeGreaterThan(0);
+ });
+
+ it('should render all role names joined by comma when there are at most 2 roles', () => {
+ const props = createMockMemberTableProps({
+ members: [
+ createMockMember({
+ user_id: 'usr_roles_2',
+ roles: [
+ { id: 'r1', name: 'Admin' },
+ { id: 'r2', name: 'Member' },
+ ],
+ last_login: undefined,
+ }),
+ ],
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByText('Admin, Member')).toBeInTheDocument();
+ expect(screen.queryByText(/\+\d+/)).not.toBeInTheDocument();
+ });
+
+ it('should render the first 2 role names with a "+N" suffix when there are more than 2 roles', () => {
+ const props = createMockMemberTableProps({
+ members: [
+ createMockMember({
+ user_id: 'usr_roles_3',
+ roles: [
+ { id: 'r1', name: 'Admin' },
+ { id: 'r2', name: 'Member' },
+ { id: 'r3', name: 'Viewer' },
+ { id: 'r4', name: 'Editor' },
+ ],
+ last_login: undefined,
+ }),
+ ],
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByText('Admin, Member, +2')).toBeInTheDocument();
+ });
+ });
+
+ describe('renderLastLogin', () => {
+ it('should render the relative last login label when last_login is a valid date', () => {
+ vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-05-15T12:00:00.000Z').getTime());
+
+ const props = createMockMemberTableProps({
+ members: [
+ createMockMember({
+ user_id: 'usr_login_1',
+ last_login: '2026-05-13T12:00:00.000Z',
+ roles: [],
+ }),
+ ],
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByText(/member\.table\.days/)).toBeInTheDocument();
+ });
+
+ it('should render the "never" label when last_login is undefined', () => {
+ const props = createMockMemberTableProps({
+ members: [
+ createMockMember({
+ user_id: 'usr_login_2',
+ last_login: undefined,
+ roles: [],
+ }),
+ ],
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByText('member.table.never')).toBeInTheDocument();
+ });
+
+ it('should render the "never" label when last_login is an invalid date string', () => {
+ const props = createMockMemberTableProps({
+ members: [
+ createMockMember({
+ user_id: 'usr_login_3',
+ last_login: 'not-a-date',
+ roles: [],
+ }),
+ ],
+ });
+
+ renderWithProviders( );
+
+ expect(screen.getByText('member.table.never')).toBeInTheDocument();
+ });
+ });
+
+ describe('Pagination', () => {
+ it('should call onNextPage and onPreviousPage when pagination controls are clicked', async () => {
+ const user = userEvent.setup();
+ const onNextPage = vi.fn();
+ const onPreviousPage = vi.fn();
+ const props = createMockMemberTableProps({
+ onNextPage,
+ onPreviousPage,
+ pagination: {
+ pageSize: 10,
+ currentPage: 2,
+ totalItems: 25,
+ hasNextPage: true,
+ hasPreviousPage: true,
+ },
+ });
+
+ renderWithProviders( );
+
+ await user.click(screen.getByLabelText('Go to next page'));
+ await user.click(screen.getByLabelText('Go to previous page'));
+
+ expect(onNextPage).toHaveBeenCalledTimes(1);
+ expect(onPreviousPage).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/organization-member-table-actions-column.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/organization-member-table-actions-column.tsx
new file mode 100644
index 000000000..06eec8849
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/organization-member-table-actions-column.tsx
@@ -0,0 +1,83 @@
+/**
+ * Organization member table row actions dropdown.
+ * @module organization-member-table-actions-column
+ * @internal
+ */
+
+import { MoreHorizontal, Eye, UserRoundCheck, Trash2 } from 'lucide-react';
+import * as React from 'react';
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuPortal,
+} from '@/components/ui/dropdown-menu';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type { OrganizationMemberTableActionsColumnProps } from '@/types/my-organization/member-management/organization-member-table-types';
+
+/**
+ * OrganizationMemberTableActionsColumn Component
+ * Handles the actions column for Member table with dropdown menu.
+ * @param props - Component props.
+ * @param props.member - The member to show actions for.
+ * @param props.customMessages - Custom translation messages to override defaults.
+ * @param props.onAssignRole - Callback fired when assign role action is triggered.
+ * @param props.onRemoveFromOrg - Callback fired when remove from organization action is triggered.
+ * @returns JSX element.
+ */
+export function OrganizationMemberTableActionsColumn({
+ member,
+ customMessages = {},
+ onViewDetails,
+ onAssignRole,
+ onRemoveFromOrg,
+}: OrganizationMemberTableActionsColumnProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ const handleViewDetails = React.useCallback(() => {
+ onViewDetails?.(member?.user_id ?? '');
+ }, [member, onViewDetails]);
+
+ const handleAssignRole = React.useCallback(() => {
+ onAssignRole?.(member);
+ }, [member, onAssignRole]);
+
+ const handleRemoveFromOrg = React.useCallback(() => {
+ onRemoveFromOrg?.(member);
+ }, [member, onRemoveFromOrg]);
+
+ return (
+
+
+
+
+ {t('member.actions.menu_label')}
+
+
+
+
+
+ {t('member.actions.view_details')}
+
+
+
+ {t('member.actions.assign_role')}
+
+
+
+ {t('member.actions.remove_from_org')}
+
+
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/organization-member-table.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/organization-member-table.tsx
new file mode 100644
index 000000000..7aaa07d8c
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/members-table/organization-member-table.tsx
@@ -0,0 +1,208 @@
+/**
+ * Organization member table component.
+ * @module organization-member-table
+ * @internal
+ */
+
+import type { OrgMember } from '@auth0/universal-components-core';
+import * as React from 'react';
+
+import { OrganizationMemberTableActionsColumn } from './organization-member-table-actions-column';
+
+import { formatDate } from '@/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/utils';
+import { SearchFilter } from '@/components/auth0/my-organization/shared/member-management/shared/search-filter/search-filter';
+import { DataPagination } from '@/components/auth0/shared/data-pagination';
+import { DataTable, type Column } from '@/components/auth0/shared/data-table';
+import { Avatar, AvatarFallback } from '@/components/ui/avatar';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import { cn } from '@/lib/utils';
+import {
+ getInitials,
+ getMemberDisplayName,
+ getRelativeLastLoginLabel,
+} from '@/lib/utils/my-organization/member-management/member-management-utils';
+import type { OrganizationMemberTableProps } from '@/types/my-organization/member-management/organization-member-table-types';
+
+/**
+ * Organization member table component.
+ * Displays members with search, filtering, and pagination.
+ * @param props - The component props.
+ * @param props.members - The list of members to display.
+ * @param props.loading - Whether the table is loading.
+ * @param props.pagination - Pagination state.
+ * @param props.pageSizeOptions - Options for page size selection.
+ * @param props.filters - Current filter state.
+ * @param props.availableRoles - Available roles for filtering.
+ * @param props.onView - Callback when viewing member details.
+ * @param props.onAssignRole - Callback when assigning a role to a member.
+ * @param props.onRemoveFromOrg - Callback when removing a member from the organization.
+ * @param props.onNextPage - Callback when navigating to the next page.
+ * @param props.onPreviousPage - Callback when navigating to the previous page.
+ * @param props.onPageSizeChange - Callback when page size changes.
+ * @param props.onRoleFilterChange - Callback when role filter changes.
+ * @param props.onSearchTermChange - Callback when search term changes.
+ * @param props.className - Optional CSS class name.
+ * @returns The member table component.
+ */
+export function OrganizationMemberTable({
+ members,
+ loading = false,
+ pagination,
+ pageSizeOptions,
+ filters,
+ customMessages = {},
+ availableRoles,
+ sortConfig,
+ className,
+ onSortChange,
+ onView,
+ onAssignRole,
+ onRemoveFromOrg,
+ onNextPage,
+ onPreviousPage,
+ onPageSizeChange,
+ onRoleFilterChange,
+ onSearchTermChange,
+}: OrganizationMemberTableProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ const renderName = React.useCallback((member: OrgMember) => {
+ const displayName = getMemberDisplayName(member);
+ const initials = getInitials(displayName);
+ return (
+
+
+ {initials}
+
+
+
{displayName}
+
{member.email ?? '-'}
+
+
+ );
+ }, []);
+
+ const renderRoles = React.useCallback((member: OrgMember) => {
+ const roleNames = member.roles?.map((role) => role.name) ?? [];
+
+ if (roleNames.length === 0) {
+ return - ;
+ }
+
+ const visibleRoles = roleNames.slice(0, 2).join(', ');
+ const remainingCount = roleNames.length - 2;
+ const fullRoles = roleNames.join(', ');
+
+ return (
+
+
+
+ {visibleRoles}
+ {remainingCount > 0 ? `, +${remainingCount}` : ''}
+
+
+ {fullRoles}
+
+ );
+ }, []);
+
+ const renderLastLogin = React.useCallback(
+ (member: OrgMember) => {
+ const label = getRelativeLastLoginLabel(member.last_login, t);
+ if (!member.last_login || Number.isNaN(new Date(member.last_login).getTime())) {
+ return {label} ;
+ }
+ return (
+
+
+ {label}
+
+ {formatDate(member.last_login)}
+
+ );
+ },
+ [t],
+ );
+
+ const columns: Column[] = React.useMemo(
+ () => [
+ {
+ type: 'text',
+ accessorKey: 'name',
+ title: t('member.table.columns.name'),
+ enableSorting: false,
+ render: renderName,
+ },
+ {
+ type: 'text',
+ accessorKey: 'roles',
+ title: t('member.table.columns.roles'),
+ enableSorting: false,
+ render: renderRoles,
+ },
+ {
+ type: 'custom',
+ accessorKey: 'last_login',
+ title: t('member.table.columns.last_login'),
+ enableSorting: false,
+ render: renderLastLogin,
+ },
+ {
+ type: 'actions',
+ title: '',
+ render: (member) => (
+
+ ),
+ },
+ ],
+ [t, onView, onAssignRole, onRemoveFromOrg, renderName, renderRoles, renderLastLogin],
+ );
+
+ return (
+
+
+
+
+
+ {!loading && (members.length > 0 || pagination.hasPreviousPage) && (
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/__tests__/organization-member-assign-roles-modal.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/__tests__/organization-member-assign-roles-modal.test.tsx
new file mode 100644
index 000000000..e6067107d
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/__tests__/organization-member-assign-roles-modal.test.tsx
@@ -0,0 +1,152 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, afterEach, vi } from 'vitest';
+
+import { OrganizationMemberAssignRolesModal } from '@/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-assign-roles-modal';
+import { renderWithProviders } from '@/tests/utils';
+import {
+ createMockAssignRolesModalProps,
+ createMockMemberRole,
+ createMockAvailableRoles,
+} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+
+describe('OrganizationMemberAssignRolesModal', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('isOpen', () => {
+ it('should render the modal when isOpen is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('should not render the modal when isOpen is false', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('content', () => {
+ it('should display the modal title', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('member.detail.roles.assign_modal.title')).toBeInTheDocument();
+ });
+
+ it('should render submit and cancel buttons', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'member.detail.roles.assign_modal.submit_button' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'member.detail.roles.assign_modal.cancel_button' }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('no roles available', () => {
+ it('should show no roles message when all roles are already assigned', () => {
+ const availableRoles = createMockAvailableRoles();
+ const assignedRoles = availableRoles.map((r) =>
+ createMockMemberRole({ id: r.id, name: r.name }),
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByText('member.detail.roles.assign_modal.no_roles_available'),
+ ).toBeInTheDocument();
+ });
+
+ it('should not show no roles message when there are unassigned roles', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByText('member.detail.roles.assign_modal.no_roles_available'),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('roles label', () => {
+ it('should show roles label when there are unassigned roles', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('member.detail.roles.assign_modal.roles_label')).toBeInTheDocument();
+ });
+ });
+
+ describe('submit button', () => {
+ it('should disable submit button when no roles are selected', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'member.detail.roles.assign_modal.submit_button' }),
+ ).toBeDisabled();
+ });
+
+ it('should disable submit button when isLoading is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'member.detail.roles.assign_modal.submit_button' }),
+ ).toBeDisabled();
+ });
+ });
+
+ describe('onClose', () => {
+ it('should call onClose when cancel button is clicked', async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('button', { name: 'member.detail.roles.assign_modal.cancel_button' }),
+ );
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/__tests__/organization-member-detail-roles-tab.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/__tests__/organization-member-detail-roles-tab.test.tsx
new file mode 100644
index 000000000..9b0abc469
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/__tests__/organization-member-detail-roles-tab.test.tsx
@@ -0,0 +1,193 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, afterEach, vi } from 'vitest';
+
+import { OrganizationMemberDetailRolesTab } from '@/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-detail-roles-tab';
+import { renderWithProviders } from '@/tests/utils';
+import {
+ createMockRolesTabProps,
+ createMockMemberRole,
+} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+
+describe('OrganizationMemberDetailRolesTab', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('should render the roles section title', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText('member.detail.roles.title')).toBeInTheDocument();
+ });
+
+ it('should render the roles description', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText('member.detail.roles.description')).toBeInTheDocument();
+ });
+
+ it('should render column headers', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText('member.detail.roles.table.name')).toBeInTheDocument();
+ expect(screen.getByText('member.detail.roles.table.description')).toBeInTheDocument();
+ });
+ });
+
+ describe('roles data', () => {
+ it('should render role names in the table', () => {
+ const memberRoles = [
+ createMockMemberRole({ id: 'rol_1', name: 'Admin', description: 'Admin role' }),
+ createMockMemberRole({ id: 'rol_2', name: 'Viewer', description: 'Viewer role' }),
+ ];
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('Admin')).toBeInTheDocument();
+ expect(screen.getByText('Viewer')).toBeInTheDocument();
+ });
+
+ it('should render role descriptions in the table', () => {
+ const memberRoles = [
+ createMockMemberRole({ id: 'rol_1', name: 'Admin', description: 'Administrator role' }),
+ ];
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('Administrator role')).toBeInTheDocument();
+ });
+
+ it('should not display description text when description is absent', () => {
+ const memberRoles = [
+ createMockMemberRole({ id: 'rol_1', name: 'Admin', description: undefined }),
+ ];
+
+ renderWithProviders(
+ ,
+ );
+
+ // The role name should still render
+ expect(screen.getByText('Admin')).toBeInTheDocument();
+ // The specific description text from the mock default is not present
+ expect(screen.queryByText('Administrator role')).not.toBeInTheDocument();
+ });
+
+ it('should show empty state message when there are no roles', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('member.detail.roles.table.empty_message')).toBeInTheDocument();
+ });
+ });
+
+ describe('assign roles button', () => {
+ it('should show assign button when readOnly is false', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: /member\.detail\.roles\.assign_button/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('should hide assign button when readOnly is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByRole('button', { name: /member\.detail\.roles\.assign_button/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should call onAssignRolesClick when assign button is clicked', async () => {
+ const user = userEvent.setup();
+ const onAssignRolesClick = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('button', { name: /member\.detail\.roles\.assign_button/i }),
+ );
+
+ expect(onAssignRolesClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('remove role button', () => {
+ it('should render trash icon button for each role when not readOnly', () => {
+ const memberRoles = [
+ createMockMemberRole({ id: 'rol_1', name: 'Admin' }),
+ createMockMemberRole({ id: 'rol_2', name: 'Viewer' }),
+ ];
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getAllByLabelText('member.detail.roles.table.remove_button_label'),
+ ).toHaveLength(2);
+ });
+
+ it('should not render trash icon when readOnly is true', () => {
+ const memberRoles = [createMockMemberRole({ id: 'rol_1', name: 'Admin' })];
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByLabelText('member.detail.roles.table.remove_button_label'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('should call onRemoveRoles with the role when trash button is clicked', async () => {
+ const user = userEvent.setup();
+ const onRemoveRoles = vi.fn();
+ const role = createMockMemberRole({ id: 'rol_1', name: 'Admin' });
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByLabelText('member.detail.roles.table.remove_button_label'));
+
+ expect(onRemoveRoles).toHaveBeenCalledTimes(1);
+ expect(onRemoveRoles).toHaveBeenCalledWith([role]);
+ });
+
+ it('should disable trash button for role being removed', () => {
+ const role = createMockMemberRole({ id: 'rol_1', name: 'Admin' });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByLabelText('member.detail.roles.table.remove_button_label')).toBeDisabled();
+ });
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/__tests__/organization-member-remove-role-modal.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/__tests__/organization-member-remove-role-modal.test.tsx
new file mode 100644
index 000000000..52cbc6c57
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/__tests__/organization-member-remove-role-modal.test.tsx
@@ -0,0 +1,153 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, afterEach, vi } from 'vitest';
+
+import { OrganizationMemberRemoveRoleModal } from '@/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-remove-role-modal';
+import { renderWithProviders } from '@/tests/utils';
+import {
+ createMockRemoveRoleModalProps,
+ createMockMemberRole,
+} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+
+describe('OrganizationMemberRemoveRoleModal', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('isOpen', () => {
+ it('should render the modal when isOpen is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('should not render the modal when isOpen is false', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('content', () => {
+ it('should display the confirm title', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('member.detail.roles.remove_confirm.title')).toBeInTheDocument();
+ });
+
+ it('should render confirm and cancel buttons', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', {
+ name: 'member.detail.roles.remove_confirm.confirm_button',
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', {
+ name: 'member.detail.roles.remove_confirm.cancel_button',
+ }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('role', () => {
+ it('should handle empty roles gracefully', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('should render with a specific role', () => {
+ const role = createMockMemberRole({ id: 'rol_1', name: 'Manager' });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+ });
+
+ describe('isLoading', () => {
+ it('should disable cancel button when isLoading is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', {
+ name: 'member.detail.roles.remove_confirm.cancel_button',
+ }),
+ ).toBeDisabled();
+ });
+
+ it('should enable buttons when isLoading is false', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', {
+ name: 'member.detail.roles.remove_confirm.cancel_button',
+ }),
+ ).toBeEnabled();
+ });
+ });
+
+ describe('onConfirm', () => {
+ it('should call onConfirm when confirm button is clicked', async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('button', {
+ name: 'member.detail.roles.remove_confirm.confirm_button',
+ }),
+ );
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('onClose', () => {
+ it('should call onClose when cancel button is clicked', async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('button', {
+ name: 'member.detail.roles.remove_confirm.cancel_button',
+ }),
+ );
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-assign-roles-modal.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-assign-roles-modal.tsx
new file mode 100644
index 000000000..037041241
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-assign-roles-modal.tsx
@@ -0,0 +1,114 @@
+/**
+ * Modal for assigning roles to a member.
+ * @module organization-member-assign-roles-modal
+ * @internal
+ */
+
+import * as React from 'react';
+
+import { Button } from '@/components/ui/button';
+import { Combobox } from '@/components/ui/combobox';
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type { OrganizationMemberAssignRolesModalProps } from '@/types/my-organization/member-management/organization-member-detail-types';
+
+/**
+ * Renders the assign roles dialog for selecting and assigning roles to a member.
+ * @param props - Component props
+ * @returns The rendered assign roles dialog element
+ */
+export function OrganizationMemberAssignRolesModal({
+ isOpen,
+ isLoading = false,
+ availableRoles,
+ assignedRoles,
+ customMessages,
+ selectedMember,
+ onClose,
+ onAssign,
+}: OrganizationMemberAssignRolesModalProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+ const [selectedRoles, setSelectedRoles] = React.useState([]);
+ const userId = selectedMember?.user_id ?? null;
+
+ React.useEffect(() => {
+ if (!isOpen) {
+ setSelectedRoles([]);
+ }
+ }, [isOpen]);
+
+ const assignedRoleIds = React.useMemo(
+ () => new Set(assignedRoles.map((r) => r.id)),
+ [assignedRoles],
+ );
+
+ const unassignedRoles = React.useMemo(
+ () => availableRoles.filter((r) => !assignedRoleIds.has(r.id)),
+ [availableRoles, assignedRoleIds],
+ );
+
+ const handleOpenChange = React.useCallback(
+ (open: boolean) => {
+ if (!open) {
+ setSelectedRoles([]);
+ onClose();
+ }
+ },
+ [onClose],
+ );
+
+ const handleSubmit = React.useCallback(() => {
+ if (selectedRoles.length > 0) {
+ onAssign(selectedRoles, userId);
+ }
+ }, [selectedRoles, onAssign]);
+
+ return (
+
+
+
+ {t('member.detail.roles.assign_modal.title')}
+
+
+
+ {unassignedRoles.length === 0 ? (
+
+ {t('member.detail.roles.assign_modal.no_roles_available')}
+
+ ) : (
+ <>
+
{t('member.detail.roles.assign_modal.roles_label')}
+
({ value: r.id, label: r.name }))}
+ value={selectedRoles}
+ onChange={(val) => setSelectedRoles(Array.isArray(val) ? val : [val])}
+ placeholder={t('member.detail.roles.assign_modal.roles_placeholder')}
+ disabled={isLoading}
+ />
+ >
+ )}
+
+
+
+
+ {t('member.detail.roles.assign_modal.cancel_button')}
+
+
+ {t('member.detail.roles.assign_modal.submit_button')}
+
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-detail-roles-tab.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-detail-roles-tab.tsx
new file mode 100644
index 000000000..32d24d379
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-detail-roles-tab.tsx
@@ -0,0 +1,95 @@
+/**
+ * Member detail roles tab component.
+ * @module organization-member-detail-roles-tab
+ * @internal
+ */
+
+import type { Role } from '@auth0/universal-components-core';
+import { Plus, Trash2 } from 'lucide-react';
+import * as React from 'react';
+
+import { DataTable, type Column } from '@/components/auth0/shared/data-table';
+import { Button } from '@/components/ui/button';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type { OrganizationMemberDetailRolesTabProps } from '@/types/my-organization/member-management/organization-member-detail-types';
+
+/**
+ * Renders the roles tab for a member detail view with a table and assign/remove actions.
+ * @param props - Component props
+ * @returns The rendered roles tab element
+ */
+export function OrganizationMemberDetailRolesTab({
+ memberRoles,
+ isLoading = false,
+ removingRoleIds = [],
+ readOnly = false,
+ customMessages,
+ onAssignRolesClick,
+ onRemoveRoles,
+}: OrganizationMemberDetailRolesTabProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ const columns: Column[] = React.useMemo(
+ () => [
+ {
+ type: 'text',
+ accessorKey: 'name',
+ title: t('member.detail.roles.table.name'),
+ enableSorting: false,
+ render: (role) => {role.name} ,
+ },
+ {
+ type: 'text',
+ accessorKey: 'description',
+ title: t('member.detail.roles.table.description'),
+ enableSorting: false,
+ render: (role) => {role.description ?? '—'} ,
+ },
+ {
+ type: 'actions',
+ title: '',
+ enableSorting: false,
+ render: (role) =>
+ readOnly ? null : (
+ onRemoveRoles([role])}
+ aria-label={t('member.detail.roles.table.remove_button_label', {
+ roleName: role.name,
+ })}
+ >
+
+
+ ),
+ },
+ ],
+ [t, readOnly, removingRoleIds, onRemoveRoles],
+ );
+
+ return (
+
+
+
+
{t('member.detail.roles.title')}
+
{t('member.detail.roles.description')}
+
+ {!readOnly && (
+
+
+ {t('member.detail.roles.assign_button')}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-remove-role-modal.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-remove-role-modal.tsx
new file mode 100644
index 000000000..35ac51138
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-remove-role-modal.tsx
@@ -0,0 +1,74 @@
+/**
+ * Confirmation modal for removing a role from a member.
+ * @module organization-member-remove-role-modal
+ * @internal
+ */
+
+import * as React from 'react';
+
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Spinner } from '@/components/ui/spinner';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type { OrganizationMemberRemoveRoleModalProps } from '@/types/my-organization/member-management/organization-member-detail-types';
+
+/**
+ * Renders the remove role confirmation dialog.
+ * @param props - Component props
+ * @returns The rendered confirmation dialog element
+ */
+export function OrganizationMemberRemoveRoleModal({
+ isOpen,
+ isLoading = false,
+ roles,
+ memberName,
+ customMessages,
+ onClose,
+ onConfirm,
+}: OrganizationMemberRemoveRoleModalProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+ const isPlural = roles.length > 1;
+
+ return (
+ !open && onClose()}>
+
+
+
+ {t(
+ isPlural
+ ? 'member.detail.roles.remove_confirm.title_plural'
+ : 'member.detail.roles.remove_confirm.title',
+ )}
+
+
+
+ <>
+ {t.trans('member.detail.roles.remove_confirm.description', {
+ vars: { roleName: roles.map((r) => r.name).join(', '), memberName },
+ components: { bold: (children: string) => {children} },
+ })}
+ >
+
+
+
+ {t('member.detail.roles.remove_confirm.cancel_button')}
+
+
+ {isLoading ? (
+
+ ) : (
+ t('member.detail.roles.remove_confirm.confirm_button')
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/__tests__/organization-member-user-details.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/__tests__/organization-member-user-details.test.tsx
new file mode 100644
index 000000000..56ff0570b
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/__tests__/organization-member-user-details.test.tsx
@@ -0,0 +1,152 @@
+import { screen } from '@testing-library/react';
+import { describe, it, expect, afterEach, vi } from 'vitest';
+
+import { OrganizationMemberUserDetails } from '@/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/organization-member-user-details';
+import { renderWithProviders } from '@/tests/utils';
+import {
+ createMockUserDetailsProps,
+ createMockMember,
+} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+
+describe('OrganizationMemberUserDetails', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('should render the user details card', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText('member.detail.user_details.title')).toBeInTheDocument();
+ });
+
+ it('should render all field labels', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText('member.detail.user_details.name')).toBeInTheDocument();
+ expect(screen.getByText('member.detail.user_details.email')).toBeInTheDocument();
+ expect(screen.getByText('member.detail.user_details.phone_number')).toBeInTheDocument();
+ expect(screen.getByText('member.detail.user_details.provider')).toBeInTheDocument();
+ expect(screen.getByText('member.detail.user_details.created_at')).toBeInTheDocument();
+ expect(screen.getByText('member.detail.user_details.last_login')).toBeInTheDocument();
+ });
+ });
+
+ describe('member name', () => {
+ it('should display the member name', () => {
+ const member = createMockMember({ name: 'Jane Doe' });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('Jane Doe')).toBeInTheDocument();
+ });
+
+ it('should display "—" when name is missing', () => {
+ const member = createMockMember({ name: undefined });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ describe('email field', () => {
+ it('should display the email as a copyable field when present', () => {
+ const member = createMockMember({ email: 'user@example.com' });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('user@example.com')).toBeInTheDocument();
+ });
+
+ it('should display "—" when email is missing', () => {
+ const member = createMockMember({ email: undefined });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ describe('phone number field', () => {
+ it('should display phone number as copyable when present', () => {
+ const member = {
+ ...createMockMember(),
+ phone_number: '+1234567890',
+ } as Parameters[0]['member'];
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('+1234567890')).toBeInTheDocument();
+ });
+
+ it('should display "—" when phone number is absent', () => {
+ const member = createMockMember();
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ describe('provider field', () => {
+ it('should display provider and connection from first identity when present', () => {
+ const member = {
+ ...createMockMember(),
+ identities: [{ provider: 'auth0', connection: 'Username-Password-Authentication' }],
+ } as Parameters[0]['member'];
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('auth0, Username-Password-Authentication')).toBeInTheDocument();
+ });
+
+ it('should display "—" when provider is absent', () => {
+ const member = createMockMember();
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ describe('date fields', () => {
+ it('should render created_at label', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText('member.detail.user_details.created_at')).toBeInTheDocument();
+ });
+
+ it('should render last_login label', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText('member.detail.user_details.last_login')).toBeInTheDocument();
+ });
+
+ it('should display "—" when created_at is missing', () => {
+ const member = createMockMember({ created_at: undefined });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1);
+ });
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/organization-member-user-details.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/organization-member-user-details.tsx
new file mode 100644
index 000000000..36d17295d
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/organization-member-user-details.tsx
@@ -0,0 +1,50 @@
+/**
+ * Member detail user details card component.
+ * @module organization-member-user-details
+ * @internal
+ */
+
+import * as React from 'react';
+
+import { buildMemberDetailFields } from './utils';
+
+import { CopyableText } from '@/components/auth0/shared/copyable-text';
+import { Card } from '@/components/ui/card';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type { OrganizationMemberUserDetailsProps } from '@/types/my-organization/member-management/organization-member-detail-types';
+
+/**
+ * Renders the user details card for a member showing name, email, phone, and login timestamps.
+ * @param props - Component props
+ * @returns The rendered user details card element
+ */
+export function OrganizationMemberUserDetails({
+ member,
+ customMessages,
+}: OrganizationMemberUserDetailsProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ const fields = React.useMemo(() => buildMemberDetailFields(member, t), [member, t]);
+
+ return (
+
+
+ {t('member.detail.user_details.title')}
+
+
+
+ {fields.map((field) => (
+
+ {field.label}
+ {field.copyable && field.value !== '—' ? (
+
+ ) : (
+ {field.value}
+ )}
+
+ ))}
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/utils.ts b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/utils.ts
new file mode 100644
index 000000000..de964af3a
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/utils.ts
@@ -0,0 +1,53 @@
+import type { OrgMember } from '@auth0/universal-components-core';
+
+/**
+ * Formats an ISO date string into a human-readable locale string.
+ * @param dateStr - ISO date string to format
+ * @returns Formatted date string, or an em dash if the input is absent
+ */
+export function formatDate(dateStr?: string): string {
+ if (!dateStr) return '—';
+ return new Date(dateStr).toLocaleString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+}
+
+/**
+ * Builds the list of display fields for an org member's detail view.
+ * @param member - The organization member whose details to display
+ * @param t - Translation function used to look up field labels
+ * @returns Array of label/value/copyable field descriptors
+ */
+export function buildMemberDetailFields(member: OrgMember, t: (key: string) => string) {
+ const phoneNumber = member.phone_number ?? '';
+ const firstIdentity = member.identities?.[0];
+ const provider =
+ firstIdentity?.provider && firstIdentity?.connection
+ ? `${firstIdentity.provider}, ${firstIdentity.connection}`
+ : '';
+
+ return [
+ { label: t('member.detail.user_details.name'), value: member.name ?? '—', copyable: false },
+ { label: t('member.detail.user_details.email'), value: member.email ?? '—', copyable: true },
+ {
+ label: t('member.detail.user_details.phone_number'),
+ value: phoneNumber || '—',
+ copyable: !!phoneNumber,
+ },
+ { label: t('member.detail.user_details.provider'), value: provider || '—', copyable: false },
+ {
+ label: t('member.detail.user_details.created_at'),
+ value: formatDate(member.created_at),
+ copyable: false,
+ },
+ {
+ label: t('member.detail.user_details.last_login'),
+ value: formatDate(member.last_login),
+ copyable: false,
+ },
+ ];
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/__tests__/organization-member-details-tab.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/__tests__/organization-member-details-tab.test.tsx
new file mode 100644
index 000000000..77cc510a1
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/__tests__/organization-member-details-tab.test.tsx
@@ -0,0 +1,126 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { OrganizationMemberEditDetailsTab } from '../organization-member-details-tab';
+
+import { createMockMember } from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+import { renderWithProviders } from '@/tests/utils/test-provider';
+import { mockToast } from '@/tests/utils/test-setup';
+
+mockToast();
+
+const createProps = (overrides = {}) => ({
+ member: createMockMember(),
+ customMessages: {},
+ isRemovingFromOrg: false,
+ onRemoveFromOrgClick: vi.fn(),
+ ...overrides,
+});
+
+afterEach(() => vi.clearAllMocks());
+
+describe('OrganizationMemberEditDetailsTab', () => {
+ describe('rendering', () => {
+ it('renders user details card when member is set', () => {
+ renderWithProviders( );
+ expect(screen.getByText('member.detail.user_details.title')).toBeInTheDocument();
+ });
+
+ it('does not render user details card when member is null', () => {
+ renderWithProviders( );
+ expect(screen.queryByText('member.detail.user_details.title')).not.toBeInTheDocument();
+ });
+
+ it('renders the remove-from-org card title', () => {
+ renderWithProviders( );
+ expect(screen.getByText('member.detail.actions.remove_from_org.title')).toBeInTheDocument();
+ });
+
+ it('renders the remove-from-org card description', () => {
+ renderWithProviders( );
+ expect(
+ screen.getByText('member.detail.actions.remove_from_org.description'),
+ ).toBeInTheDocument();
+ });
+
+ it('renders the remove-from-org button', () => {
+ renderWithProviders( );
+ expect(
+ screen.getByRole('button', {
+ name: /member.detail.actions.remove_from_org.button/i,
+ }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('onRemoveFromOrgClick', () => {
+ it('calls handler when remove button is clicked', async () => {
+ const user = userEvent.setup();
+ const onRemoveFromOrgClick = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ await user.click(
+ screen.getByRole('button', {
+ name: /member.detail.actions.remove_from_org.button/i,
+ }),
+ );
+ expect(onRemoveFromOrgClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('isRemovingFromOrg', () => {
+ it('remove button is disabled when true', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(
+ screen.getByRole('button', {
+ name: /member.detail.actions.remove_from_org.button/i,
+ }),
+ ).toBeDisabled();
+ });
+
+ it('remove button is enabled when false', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(
+ screen.getByRole('button', {
+ name: /member.detail.actions.remove_from_org.button/i,
+ }),
+ ).not.toBeDisabled();
+ });
+ });
+
+ describe('customMessages', () => {
+ it('overrides remove-from-org button text', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByText('Custom Button')).toBeInTheDocument();
+ });
+
+ it('overrides remove-from-org title text', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByText('Custom Title')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/__tests__/organization-member-roles-tab.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/__tests__/organization-member-roles-tab.test.tsx
new file mode 100644
index 000000000..713b4a4f5
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/__tests__/organization-member-roles-tab.test.tsx
@@ -0,0 +1,278 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { OrganizationMemberEditRolesTab } from '../organization-member-roles-tab';
+
+import {
+ createMockAvailableRoles,
+ createMockMemberRole,
+ createMockMemberRoles,
+} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+import { renderWithProviders } from '@/tests/utils/test-provider';
+import { mockToast } from '@/tests/utils/test-setup';
+import type { MemberDetailModalState } from '@/types/my-organization/member-management/organization-member-detail-types';
+
+mockToast();
+
+const noModal: MemberDetailModalState = { type: null };
+
+const createProps = (overrides = {}) => ({
+ customMessages: {},
+ memberRoles: createMockMemberRoles(),
+ availableRoles: createMockAvailableRoles(),
+ selectedRoles: [],
+ isFetchingMemberRoles: false,
+ removingRoleIds: [],
+ isAssigningRoles: false,
+ modalState: noModal,
+ onSelectedRolesChange: vi.fn(),
+ onAssignRolesClick: vi.fn(),
+ onAssignRolesCancel: vi.fn(),
+ onAssignRolesSubmit: vi.fn(),
+ onRemoveRolesClick: vi.fn(),
+ onRemoveRolesCancel: vi.fn(),
+ onRemoveRolesConfirm: vi.fn(),
+ ...overrides,
+});
+
+afterEach(() => vi.clearAllMocks());
+
+describe('OrganizationMemberEditRolesTab', () => {
+ describe('rendering', () => {
+ it('renders roles title and description', () => {
+ renderWithProviders( );
+ expect(screen.getByText('member.detail.roles.title')).toBeInTheDocument();
+ expect(screen.getByText('member.detail.roles.description')).toBeInTheDocument();
+ });
+
+ it('renders assign button when no rows selected', () => {
+ renderWithProviders( );
+ expect(
+ screen.getByRole('button', { name: /member.detail.roles.assign_button/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('renders each role name and description in the table', () => {
+ renderWithProviders( );
+ expect(screen.getByText('Admin')).toBeInTheDocument();
+ expect(screen.getByText('Member')).toBeInTheDocument();
+ expect(screen.getByText('Administrator role')).toBeInTheDocument();
+ expect(screen.getByText('Member role')).toBeInTheDocument();
+ });
+
+ it('renders empty state when memberRoles is empty', () => {
+ renderWithProviders( );
+ expect(screen.getByText('member.detail.roles.table.empty_message')).toBeInTheDocument();
+ });
+
+ it('renders loading state when isFetchingRoles is true', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.queryByText('Admin')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('onAssignRolesClick', () => {
+ it('calls handler when assign button is clicked', async () => {
+ const user = userEvent.setup();
+ const onAssignRolesClick = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ await user.click(screen.getByRole('button', { name: /member.detail.roles.assign_button/i }));
+ expect(onAssignRolesClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('row selection', () => {
+ it('when 1 role selected shows selection label and bulk remove button; hides assign button', () => {
+ const roles = createMockMemberRoles();
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByText('member.detail.roles.roles_selected')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /member.detail.roles.remove_button/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: /member.detail.roles.assign_button/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('when 2 roles selected shows plural selection label', () => {
+ const roles = createMockMemberRoles();
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByText('member.detail.roles.roles_selected_plural')).toBeInTheDocument();
+ });
+
+ it('calls onSelectedRolesChange when a row checkbox is clicked', async () => {
+ const user = userEvent.setup();
+ const onSelectedRolesChange = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ await user.click(screen.getByRole('checkbox', { name: 'data_table.select_row 1' }));
+ expect(onSelectedRolesChange).toHaveBeenCalled();
+ });
+ });
+
+ describe('onRemoveRolesClick (per-row trash)', () => {
+ it('calls handler with correct role when trash button clicked', async () => {
+ const user = userEvent.setup();
+ const onRemoveRolesClick = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ // aria-label is the raw i18n key (mock translator has no interpolation)
+ const removeButtons = screen.getAllByRole('button', {
+ name: 'member.detail.roles.table.remove_button_label',
+ });
+ await user.click(removeButtons[0]!);
+ expect(onRemoveRolesClick).toHaveBeenCalledWith(
+ expect.arrayContaining([expect.objectContaining({ name: 'Admin' })]),
+ );
+ });
+ });
+
+ describe('removingRoleIds', () => {
+ it('when matches a role id that role trash button is disabled', () => {
+ renderWithProviders(
+ ,
+ );
+ const removeButtons = screen.getAllByRole('button', {
+ name: 'member.detail.roles.table.remove_button_label',
+ });
+ // First button corresponds to Admin (rol_admin) — should be disabled
+ expect(removeButtons[0]).toBeDisabled();
+ });
+
+ it('when empty all trash buttons are enabled', () => {
+ renderWithProviders(
+ ,
+ );
+ const removeButtons = screen.getAllByRole('button', {
+ name: 'member.detail.roles.table.remove_button_label',
+ });
+ removeButtons.forEach((btn) => expect(btn).not.toBeDisabled());
+ });
+ });
+
+ describe('MemberAssignRolesModal', () => {
+ it('when modalState is assignRoles modal is visible', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByText('member.detail.roles.assign_modal.title')).toBeInTheDocument();
+ });
+
+ it('when modalState is null modal is not visible', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.queryByText('member.detail.roles.assign_modal.title')).not.toBeInTheDocument();
+ });
+
+ it('cancel clicked calls onAssignRolesCancel', async () => {
+ const user = userEvent.setup();
+ const onAssignRolesCancel = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ await user.click(
+ screen.getByRole('button', { name: /member.detail.roles.assign_modal.cancel_button/i }),
+ );
+ expect(onAssignRolesCancel).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('MemberRemoveRoleModal', () => {
+ const removeRolesState: MemberDetailModalState = {
+ type: 'removeRoles',
+ roles: [createMockMemberRole()],
+ };
+
+ it('when modalState is removeRoles modal is visible', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByText('member.detail.roles.remove_confirm.title')).toBeInTheDocument();
+ });
+
+ it('when modalState is null modal is not visible', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(
+ screen.queryByText('member.detail.roles.remove_confirm.title'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('cancel clicked calls onRemoveRolesCancel', async () => {
+ const user = userEvent.setup();
+ const onRemoveRolesCancel = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ await user.click(
+ screen.getByRole('button', {
+ name: /member.detail.roles.remove_confirm.cancel_button/i,
+ }),
+ );
+ expect(onRemoveRolesCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('confirm clicked calls onRemoveRolesConfirm', async () => {
+ const user = userEvent.setup();
+ const onRemoveRolesConfirm = vi.fn();
+ renderWithProviders(
+ ,
+ );
+ await user.click(
+ screen.getByRole('button', {
+ name: /member.detail.roles.remove_confirm.confirm_button/i,
+ }),
+ );
+ expect(onRemoveRolesConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('when isRemovingRoles is true modal shows loading indicator', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByRole('button', { name: 'Loading...' })).toBeInTheDocument();
+ });
+ });
+
+ describe('customMessages', () => {
+ it('overrides roles title text', () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.getByText('Custom Roles Title')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-details-tab.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-details-tab.tsx
new file mode 100644
index 000000000..ccb54dc0a
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-details-tab.tsx
@@ -0,0 +1,75 @@
+/**
+ * Organization member edit details tab.
+ * @module organization-member-details-tab
+ */
+
+import * as React from 'react';
+
+import { OrganizationMemberUserDetails } from '@/components/auth0/my-organization/shared/member-management/members/organization-member-user-details/organization-member-user-details';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type {
+ OrganizationMemberEditDetailsTabProps,
+ RemoveMemberFromOrganizationCardProps,
+} from '@/types/my-organization/member-management/organization-member-detail-types';
+
+/**
+ * Card with a button to remove the member from the organization.
+ * @param props - Component props containing handlers and loading state
+ * @returns The rendered remove-from-org card element
+ */
+function RemoveMemberFromOrganizationCard({
+ customMessages,
+ isRemovingFromOrg,
+ onRemoveFromOrgClick,
+}: RemoveMemberFromOrganizationCardProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ return (
+
+
+
+ {t('member.detail.actions.remove_from_org.title')}
+
+
+ {t('member.detail.actions.remove_from_org.description')}
+
+
+
+ {t('member.detail.actions.remove_from_org.button')}
+
+
+ );
+}
+
+/**
+ * Details tab — user details + danger zone actions.
+ * @param props - Component props
+ * @returns The rendered details tab element
+ */
+export function OrganizationMemberEditDetailsTab(
+ props: OrganizationMemberEditDetailsTabProps,
+): React.JSX.Element {
+ return (
+
+ {props.member && (
+
+ )}
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-roles-tab.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-roles-tab.tsx
new file mode 100644
index 000000000..afc861b50
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-roles-tab.tsx
@@ -0,0 +1,236 @@
+/**
+ * Organization member edit roles tab.
+ * @module organization-member-roles-tab
+ */
+
+import type { Role } from '@auth0/universal-components-core';
+import { Plus, Trash2 } from 'lucide-react';
+import * as React from 'react';
+
+import { OrganizationMemberAssignRolesModal } from '@/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-assign-roles-modal';
+import { OrganizationMemberRemoveRoleModal } from '@/components/auth0/my-organization/shared/member-management/members/organization-member-roles/organization-member-remove-role-modal';
+import { DataTable, type Column } from '@/components/auth0/shared/data-table';
+import { Button } from '@/components/ui/button';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type {
+ OrganizationMemberEditRolesTabProps,
+ OrganizationMemberEditRolesTableProps,
+ RolesTabHeaderProps,
+} from '@/types/my-organization/member-management/organization-member-detail-types';
+
+/**
+ * Renders the header section of the roles tab with conditional action buttons.
+ * @param props - Component props
+ * @param props.selectedRoles - The currently selected roles
+ * @param props.customMessages - Optional custom message overrides
+ * @param props.onAssignRolesClick - Handler for the assign roles button click
+ * @param props.onRemoveSelectedRoles - Handler for removing all selected roles
+ * @returns The rendered roles tab header element
+ */
+function RolesTabHeader({
+ selectedRoles,
+ orgName,
+ customMessages,
+ onAssignRolesClick,
+ onRemoveSelectedRoles,
+}: RolesTabHeaderProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ return (
+
+
+
{t('member.detail.roles.title')}
+
+ {t('member.detail.roles.description', { orgName })}
+
+
+
+ {selectedRoles.length > 0 ? (
+ <>
+
+ {t(
+ selectedRoles.length === 1
+ ? 'member.detail.roles.roles_selected'
+ : 'member.detail.roles.roles_selected_plural',
+ { count: selectedRoles.length },
+ )}
+
+
+ {t(
+ selectedRoles.length === 1
+ ? 'member.detail.roles.remove_button'
+ : 'member.detail.roles.remove_button_plural',
+ )}
+
+ >
+ ) : (
+
+
+ {t('member.detail.roles.assign_button')}
+
+ )}
+
+
+ );
+}
+
+/**
+ * Renders the roles data table for the member detail roles tab.
+ * @param props - Component props
+ * @param props.memberRoles - The list of roles assigned to the member
+ * @param props.isLoading - Whether the roles data is loading
+ * @param props.removingRoleIds - IDs of roles currently being removed
+ * @param props.selectedRoles - The currently selected roles
+ * @param props.customMessages - Optional custom message overrides
+ * @param props.onRemoveRoles - Handler called when role removal is requested
+ * @param props.onSelectedRolesChange - Handler called when row selection changes
+ * @returns The rendered roles table element
+ */
+function OrganizationMemberEditRolesTable({
+ memberRoles,
+ isLoading = false,
+ removingRoleIds = [],
+ selectedRoles,
+ customMessages,
+ onRemoveRoles,
+ onSelectedRolesChange,
+}: OrganizationMemberEditRolesTableProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ const columns: Column[] = React.useMemo(
+ () => [
+ {
+ type: 'text',
+ accessorKey: 'name',
+ title: t('member.detail.roles.table.name'),
+ enableSorting: true,
+ render: (role) => {role.name} ,
+ },
+ {
+ type: 'text',
+ accessorKey: 'description',
+ title: t('member.detail.roles.table.description'),
+ enableSorting: true,
+ render: (role) => {role.description ?? '—'} ,
+ },
+ {
+ type: 'actions',
+ title: '',
+ enableSorting: false,
+ render: (role) => (
+
+ onRemoveRoles([role])}
+ aria-label={t('member.detail.roles.table.remove_button_label', {
+ roleName: role.name,
+ })}
+ >
+
+
+
+ ),
+ },
+ ],
+ [t, removingRoleIds, onRemoveRoles],
+ );
+
+ return (
+ `${t('data_table.select_row')} ${index + 1}`,
+ }}
+ selectedRows={selectedRoles}
+ onSelectedRowsChange={onSelectedRolesChange}
+ getRowId={(role) => role.id}
+ />
+ );
+}
+
+/**
+ * Roles tab — header, table, and role modals.
+ * @param props - Component props containing state and handlers
+ * @returns The rendered roles tab element
+ */
+export function OrganizationMemberEditRolesTab({
+ customMessages,
+ orgName,
+ memberName,
+ memberRoles,
+ availableRoles,
+ selectedRoles,
+ isFetchingMemberRoles,
+ isFetchingAvailableRoles,
+ removingRoleIds,
+ isAssigningRoles,
+ isRemovingRoles = false,
+ modalState,
+ onSelectedRolesChange,
+ onAssignRolesClick,
+ onAssignRolesCancel,
+ onAssignRolesSubmit,
+ onRemoveRolesClick,
+ onRemoveRolesCancel,
+ onRemoveRolesConfirm,
+}: OrganizationMemberEditRolesTabProps): React.JSX.Element {
+ const isRemoveRolesModal = modalState.type === 'removeRoles';
+ const isAssignRolesModal = modalState.type === 'assignRoles';
+ const rolesToRemove = isRemoveRolesModal ? modalState.roles : [];
+
+ const handleRemoveSelectedRoles = React.useCallback(() => {
+ onRemoveRolesClick(selectedRoles);
+ }, [selectedRoles, onRemoveRolesClick]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
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
new file mode 100644
index 000000000..d35fcca86
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/invitation-create/__tests__/organization-invitation-create-modal.test.tsx
@@ -0,0 +1,218 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi, describe, it, expect, afterEach } from 'vitest';
+
+import { OrganizationInvitationCreateModal } from '@/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal';
+import { renderWithProviders } from '@/tests/utils';
+import {
+ createMockCreateModalProps,
+ createMockRoles,
+ createMockProviders,
+} from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks';
+
+describe('OrganizationInvitationCreateModal', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('isOpen', () => {
+ describe('when is true', () => {
+ it('should render the modal', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText('invitation.create.title')).toBeInTheDocument();
+ });
+ });
+
+ describe('when is false', () => {
+ it('should not render the modal content', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('isLoading', () => {
+ describe('when is true', () => {
+ it('should disable form inputs', () => {
+ renderWithProviders(
+ ,
+ );
+
+ const emailInput = screen.getByPlaceholderText('invitation.create.email_placeholder');
+ expect(emailInput).toBeDisabled();
+ });
+
+ it('should disable cancel and submit buttons', () => {
+ renderWithProviders(
+ ,
+ );
+
+ const cancelButton = screen.getByRole('button', {
+ name: 'invitation.create.cancel_button',
+ });
+ expect(cancelButton).toBeDisabled();
+ });
+ });
+
+ describe('when is false', () => {
+ it('should enable form inputs', () => {
+ renderWithProviders(
+ ,
+ );
+
+ const emailInput = screen.getByPlaceholderText('invitation.create.email_placeholder');
+ expect(emailInput).toBeEnabled();
+ });
+ });
+ });
+
+ describe('className', () => {
+ describe('when className is provided', () => {
+ it('should apply custom class to modal', () => {
+ const customClass = 'custom-modal-class';
+
+ renderWithProviders(
+ ,
+ );
+
+ const modalContent = document.querySelector('[data-slot="dialog-content"]');
+ expect(modalContent).toHaveClass(customClass);
+ });
+ });
+ });
+
+ describe('onClose', () => {
+ describe('when modal is closed', () => {
+ it('should call onClose callback via cancel button', async () => {
+ const user = userEvent.setup();
+ const mockOnClose = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const cancelButton = screen.getByRole('button', {
+ name: 'invitation.create.cancel_button',
+ });
+ await user.click(cancelButton);
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('email input', () => {
+ it('should render email input field', () => {
+ renderWithProviders( );
+
+ expect(
+ screen.getByPlaceholderText('invitation.create.email_placeholder'),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/invitation\.create\.email_label/)).toBeInTheDocument();
+ });
+
+ it('should show helper text by default', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText('invitation.create.email_helper')).toBeInTheDocument();
+ });
+ });
+
+ describe('submit', () => {
+ it('should disable submit button when no emails are added', () => {
+ renderWithProviders( );
+
+ const submitButton = screen.getByRole('button', {
+ name: 'invitation.create.submit_button',
+ });
+ expect(submitButton).toBeDisabled();
+ });
+
+ it('should show creating text when isLoading is true', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.create.creating')).toBeInTheDocument();
+ });
+ });
+
+ describe('availableRoles', () => {
+ describe('when roles are provided', () => {
+ it('should render roles combobox', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.create.roles_label')).toBeInTheDocument();
+ });
+ });
+
+ describe('when no roles are provided', () => {
+ it('should still render roles section', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.create.roles_label')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('availableProviders', () => {
+ describe('when providers are provided', () => {
+ it('should render provider dropdown', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.create.provider_label')).toBeInTheDocument();
+ });
+ });
+
+ describe('when no providers are provided', () => {
+ it('should still render provider section', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('invitation.create.provider_label')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('description', () => {
+ it('should render description text', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText('invitation.create.description')).toBeInTheDocument();
+ });
+ });
+});
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
new file mode 100644
index 000000000..88796123e
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal.tsx
@@ -0,0 +1,284 @@
+/**
+ * Organization invitation create modal component.
+ * @module organization-invitation-create-modal
+ */
+
+import { createInvitationCreateSchema } from '@auth0/universal-components-core';
+import * as React from 'react';
+
+import { Button } from '@/components/ui/button';
+import { Combobox } from '@/components/ui/combobox';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { TextFieldGroup } from '@/components/ui/text-field-group';
+import type { ChipItem } from '@/components/ui/text-field-group';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type { OrganizationInvitationCreateModalProps } from '@/types/my-organization/member-management/organization-invitation-table-types';
+
+/**
+ * Modal for creating a new invitation.
+ * Supports multiple email addresses, role selection, and provider selection.
+ * Validation rules can be overridden via the `schema` prop.
+ *
+ * @param props - The component props.
+ * @param props.isOpen - Whether the modal is open.
+ * @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.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.
+ * @param props.onCreate - Callback when invitation is created.
+ * @param props.className - Optional CSS class name.
+ * @returns The modal component.
+ */
+export function OrganizationInvitationCreateModal({
+ isOpen,
+ isLoading = false,
+ customMessages = {},
+ availableRoles = [],
+ availableProviders = [],
+ inviterName,
+ schema,
+ onClose,
+ onCreate,
+ className,
+}: OrganizationInvitationCreateModalProps): React.JSX.Element {
+ const { t } = useTranslator('member_management', customMessages);
+
+ const validationConfig = React.useMemo(
+ () => createInvitationCreateSchema(schema, t('invitation.create.email_invalid_error')),
+ [schema, t],
+ );
+
+ const [emailInput, setEmailInput] = React.useState('');
+ const [emailChips, setEmailChips] = React.useState([]);
+ const [selectedRoles, setSelectedRoles] = React.useState([]);
+ const [selectedProvider, setSelectedProvider] = React.useState();
+ 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);
+ }, []);
+
+ const hasInvalidChips = React.useMemo(
+ () => emailChips.some((chip) => chip.variant === 'destructive'),
+ [emailChips],
+ );
+
+ const handleEmailChipAdd = React.useCallback(
+ (value: string) => {
+ const trimmedEmail = value.trim().replace(/,/g, '');
+
+ if (!trimmedEmail) return;
+
+ if (emailChips.length >= validationConfig.maxEmails) {
+ setEmailError(t('invitation.create.email_limit_error'));
+ return;
+ }
+
+ if (emailChips.some((chip) => chip.value === trimmedEmail)) {
+ setEmailError(t('invitation.create.email_duplicate_error'));
+ return;
+ }
+
+ const result = validationConfig.emailSchema.safeParse(trimmedEmail);
+ if (!result.success) {
+ setEmailChips((prev) => [
+ ...prev,
+ { label: trimmedEmail, value: trimmedEmail, variant: 'destructive' },
+ ]);
+ setEmailInput('');
+ setEmailError(t('invitation.create.email_invalid_error'));
+ return;
+ }
+
+ setEmailChips((prev) => [...prev, { label: trimmedEmail, value: trimmedEmail }]);
+ setEmailInput('');
+ setEmailError(undefined);
+ },
+ [emailChips, validationConfig, t],
+ );
+
+ const handleEmailChipRemove = React.useCallback((value: string) => {
+ setEmailChips((prev) => {
+ const updated = prev.filter((chip) => chip.value !== value);
+ if (!updated.some((chip) => chip.variant === 'destructive')) {
+ setEmailError(undefined);
+ }
+ return updated;
+ });
+ }, []);
+
+ const handleRoleChange = React.useCallback((value: string | string[]) => {
+ setSelectedRoles(Array.isArray(value) ? value : value ? [value] : []);
+ }, []);
+
+ const handleProviderChange = React.useCallback((value: string) => {
+ setSelectedProvider(value || undefined);
+ }, []);
+
+ 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);
+ } else if (!result.success) {
+ setEmailError(t('invitation.create.email_invalid_error'));
+ return;
+ }
+ }
+
+ 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(() => {
+ resetForm();
+ onClose();
+ }, [onClose, resetForm]);
+
+ const canSubmit = React.useMemo(
+ () =>
+ !hasInvalidChips &&
+ (emailChips.length > 0 ||
+ (emailInput.trim() !== '' &&
+ validationConfig.emailSchema.safeParse(emailInput.trim()).success)),
+ [emailChips.length, emailInput, validationConfig, hasInvalidChips],
+ );
+
+ const roleOptions = React.useMemo(
+ () => availableRoles.map((role) => ({ label: role.name, value: role.id })),
+ [availableRoles],
+ );
+
+ return (
+
+
+
+ {t('invitation.create.title')}
+ {t('invitation.create.description')}
+
+
+
+
+
{t('invitation.create.email_label')}*
+
+
{t('invitation.create.email_helper')}
+ {emailError &&
{emailError}
}
+
+
+
+ {t('invitation.create.roles_label')}
+
+
+
+
+
{t('invitation.create.provider_label')}
+
+
+
+
+
+ {availableProviders.map((provider) => (
+
+ {provider.name}
+
+ ))}
+
+
+
+ {t('invitation.create.provider_helper')}
+
+
+
+
+
+
+ {t('invitation.create.cancel_button')}
+
+
+ {isLoading ? t('invitation.create.creating') : t('invitation.create.submit_button')}
+
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/shared/search-filter/__tests__/search-filter.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/search-filter/__tests__/search-filter.test.tsx
new file mode 100644
index 000000000..bf0144b90
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/search-filter/__tests__/search-filter.test.tsx
@@ -0,0 +1,117 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest';
+
+import { SearchFilter } from '@/components/auth0/my-organization/shared/member-management/shared/search-filter/search-filter';
+import { renderWithProviders } from '@/tests/utils';
+import { createMockSearchFilterProps } from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks';
+
+describe('SearchFilter', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('should render the filter when roles are provided', () => {
+ renderWithProviders( );
+
+ expect(screen.getByText(/invitation\.table\.filter_by_role/)).toBeInTheDocument();
+ });
+
+ it('should return null when no roles are provided', () => {
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('should render reset button', () => {
+ renderWithProviders( );
+
+ expect(
+ screen.getByRole('button', { name: 'invitation.table.reset_filter' }),
+ ).toBeInTheDocument();
+ });
+
+ it('should not render member search input on the invitations tab', () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.queryByPlaceholderText('Search for a member by name or email'),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('reset button', () => {
+ it('should be disabled when no active filter', () => {
+ renderWithProviders( );
+
+ const resetButton = screen.getByRole('button', {
+ name: 'invitation.table.reset_filter',
+ });
+ expect(resetButton).toBeDisabled();
+ });
+
+ it('should be enabled when there is an active filter', () => {
+ renderWithProviders(
+ ,
+ );
+
+ const resetButton = screen.getByRole('button', {
+ name: 'invitation.table.reset_filter',
+ });
+ expect(resetButton).toBeEnabled();
+ });
+
+ it('should call onRoleFilterChange with undefined when reset is clicked', async () => {
+ const user = userEvent.setup();
+ const onRoleFilterChange = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const resetButton = screen.getByRole('button', {
+ name: 'invitation.table.reset_filter',
+ });
+ await user.click(resetButton);
+
+ expect(onRoleFilterChange).toHaveBeenCalledTimes(1);
+ expect(onRoleFilterChange).toHaveBeenCalledWith(undefined);
+ });
+ });
+
+ describe('className', () => {
+ it('should apply custom class when provided', () => {
+ const customClass = 'custom-filter-class';
+
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ const filterDiv = container.firstChild as HTMLElement;
+ expect(filterDiv).toHaveClass(customClass);
+ });
+
+ it('should apply default class when no custom class provided', () => {
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ const filterDiv = container.firstChild as HTMLElement;
+ expect(filterDiv).toHaveClass('mt-8', 'mb-6');
+ });
+ });
+});
diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/shared/search-filter/search-filter.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/search-filter/search-filter.tsx
new file mode 100644
index 000000000..752ddd2c2
--- /dev/null
+++ b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/search-filter/search-filter.tsx
@@ -0,0 +1,113 @@
+/**
+ * Search and filter component for invitations.
+ * @module search-filter
+ * @internal
+ */
+
+import { X } from 'lucide-react';
+import * as React from 'react';
+
+import { Button } from '@/components/ui/button';
+import { Search } from '@/components/ui/search';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type { SearchFilterProps } from '@/types/my-organization/member-management/organization-invitation-table-types';
+
+/**
+ * Filter bar for invitation table.
+ * Shows a right-aligned role filter dropdown with a reset button.
+ * @param props - The component props.
+ * @param props.filters - Current filter state.
+ * @param props.availableRoles - Available roles for filtering.
+ * @param props.customMessages - Custom translation messages.
+ * @param props.className - Optional CSS class name.
+ * @param props.activeTab - The currently active tab (members or invitations).
+ * @param props.onRoleFilterChange - Callback fired when role filter changes.
+ * @param props.onSearchTermChange - Callback fired when search term changes.
+ * @returns The search and filter component.
+ */
+export function SearchFilter({
+ filters,
+ availableRoles = [],
+ customMessages = {},
+ className,
+ activeTab,
+ onRoleFilterChange,
+ onSearchTermChange,
+}: SearchFilterProps): React.JSX.Element | null {
+ const { t } = useTranslator('member_management', customMessages);
+ const [searchTerm, setSearchTerm] = React.useState('');
+
+ const handleRoleFilterChange = React.useCallback(
+ (value: string) => {
+ onRoleFilterChange?.(value === 'all' ? undefined : value);
+ },
+ [onRoleFilterChange],
+ );
+
+ const handleKeyDownSearch = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setSearchTerm('');
+ }
+ if (event.key === 'Enter') {
+ onSearchTermChange?.(searchTerm);
+ }
+ },
+ [searchTerm, onSearchTermChange],
+ );
+
+ const handleReset = React.useCallback(() => {
+ onRoleFilterChange?.(undefined);
+ }, [onRoleFilterChange]);
+
+ const hasActiveFilter = !!filters?.roleId;
+
+ if (availableRoles.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {activeTab === 'members' && (
+
+ )}
+
+
+
+ {t('invitation.table.filter_by_role')}:
+
+
+
+
+ {t('invitation.table.all_roles')}
+ {availableRoles.map((role) => (
+
+ {role.name}
+
+ ))}
+
+
+
+
+ {t('invitation.table.reset_filter')}
+
+
+
+ );
+}
diff --git a/packages/react/src/components/auth0/shared/__tests__/copyable-text.test.tsx b/packages/react/src/components/auth0/shared/__tests__/copyable-text.test.tsx
new file mode 100644
index 000000000..42f501ddf
--- /dev/null
+++ b/packages/react/src/components/auth0/shared/__tests__/copyable-text.test.tsx
@@ -0,0 +1,115 @@
+import { render, screen, act, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+import { CopyableText } from '@/components/auth0/shared/copyable-text';
+
+vi.mock('@/hooks/shared/use-translator', () => ({
+ useTranslator: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+describe('CopyableText', () => {
+ let writeText: ReturnType;
+
+ beforeEach(() => {
+ writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ configurable: true,
+ });
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('renders the value', () => {
+ render( );
+ expect(screen.getByText('test-value')).toBeInTheDocument();
+ });
+
+ it('renders a copy button with accessible label', () => {
+ render( );
+ expect(screen.getByRole('button', { name: 'copy' })).toBeInTheDocument();
+ });
+
+ it('calls clipboard.writeText with the value on click', async () => {
+ render( );
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'copy' }));
+ });
+ expect(writeText).toHaveBeenCalledWith('my-value');
+ });
+
+ it('calls onCopy callback after successful copy', async () => {
+ const onCopy = vi.fn();
+ render( );
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'copy' }));
+ });
+ expect(onCopy).toHaveBeenCalledOnce();
+ });
+
+ it('resets tooltip state after 1 second', async () => {
+ render( );
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'copy' }));
+ });
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(writeText).toHaveBeenCalled();
+ });
+
+ it('does not throw when clipboard write fails', async () => {
+ writeText.mockRejectedValueOnce(new Error('denied'));
+ render( );
+ await expect(
+ act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'copy' }));
+ }),
+ ).resolves.not.toThrow();
+ });
+
+ it('shows copy_failed tooltip when clipboard write fails', async () => {
+ writeText.mockRejectedValueOnce(new Error('denied'));
+ render( );
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'copy' }));
+ });
+ expect(screen.getAllByText('copy_failed').length).toBeGreaterThan(0);
+ });
+
+ it('resets tooltip to copy after 1 second on failure', async () => {
+ writeText.mockRejectedValueOnce(new Error('denied'));
+ render( );
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'copy' }));
+ });
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(screen.queryByText('copy_failed')).not.toBeInTheDocument();
+ });
+
+ it('does not call onCopy when clipboard write fails', async () => {
+ writeText.mockRejectedValueOnce(new Error('denied'));
+ const onCopy = vi.fn();
+ render( );
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'copy' }));
+ });
+ expect(onCopy).not.toHaveBeenCalled();
+ });
+
+ it('clears pending timeout on unmount without errors', async () => {
+ const { unmount } = render( );
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'copy' }));
+ });
+ expect(() => unmount()).not.toThrow();
+ });
+});
diff --git a/packages/react/src/components/auth0/shared/copyable-text.tsx b/packages/react/src/components/auth0/shared/copyable-text.tsx
new file mode 100644
index 000000000..12dc3427a
--- /dev/null
+++ b/packages/react/src/components/auth0/shared/copyable-text.tsx
@@ -0,0 +1,97 @@
+/**
+ * Inline text with copy-to-clipboard button.
+ * @module copyable-text
+ * @internal
+ */
+
+import { Copy } from 'lucide-react';
+import * as React from 'react';
+
+import { Button } from '@/components/ui/button';
+import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import { cn } from '@/lib/utils';
+
+export interface CopyableTextProps {
+ value: string;
+ className?: string;
+ buttonClassName?: string;
+ tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
+ tooltipAlign?: 'start' | 'center' | 'end';
+ onCopy?: () => void;
+}
+
+const CopyableText = React.forwardRef(
+ (
+ { value, className, buttonClassName, tooltipSide = 'top', tooltipAlign = 'end', onCopy },
+ ref,
+ ) => {
+ const { t } = useTranslator('common');
+ const [tooltipText, setTooltipText] = React.useState(t('copy'));
+ const [tooltipOpen, setTooltipOpen] = React.useState(false);
+
+ const resetTimeoutRef = React.useRef | null>(null);
+
+ React.useEffect(() => {
+ return () => {
+ if (resetTimeoutRef.current) {
+ clearTimeout(resetTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(value);
+ setTooltipText(t('copied'));
+ setTooltipOpen(true);
+ if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
+ resetTimeoutRef.current = setTimeout(() => {
+ setTooltipText(t('copy'));
+ setTooltipOpen(false);
+ }, 1000);
+ onCopy?.();
+ } catch {
+ setTooltipText(t('copy_failed'));
+ setTooltipOpen(true);
+ if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
+ resetTimeoutRef.current = setTimeout(() => {
+ setTooltipText(t('copy'));
+ setTooltipOpen(false);
+ }, 1000);
+ }
+ };
+
+ return (
+
+ {value}
+
+
+
+
+
+
+
+ {tooltipText}
+
+
+
+ );
+ },
+);
+
+CopyableText.displayName = 'CopyableText';
+
+export { CopyableText };
diff --git a/packages/react/src/components/auth0/shared/data-table.tsx b/packages/react/src/components/auth0/shared/data-table.tsx
index b8f474401..5cc6d42bb 100644
--- a/packages/react/src/components/auth0/shared/data-table.tsx
+++ b/packages/react/src/components/auth0/shared/data-table.tsx
@@ -11,13 +11,14 @@ import {
getSortedRowModel,
flexRender,
} from '@tanstack/react-table';
-import type { SortingState, ColumnDef } from '@tanstack/react-table';
+import type { SortingState, ColumnDef, RowSelectionState } from '@tanstack/react-table';
import { Copy } from 'lucide-react';
import React, { useState, useMemo } from 'react';
import { MiddleEllipsisText } from '@/components/auth0/shared/middle-ellipsis-text';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
import { InlineCode } from '@/components/ui/inline-code';
import { Spinner } from '@/components/ui/spinner';
import { Switch } from '@/components/ui/switch';
@@ -39,7 +40,7 @@ interface ActionButton extends Omit {
type AlignmentType = 'left' | 'center' | 'right';
export interface BaseColumn- {
- title: string;
+ title: string | React.ReactNode;
accessorKey: keyof Item;
width?: string;
enableSorting?: boolean;
@@ -74,6 +75,11 @@ export interface CopyColumnLabels {
copiedTooltip?: string;
}
+export interface DataTableSelectionLabels {
+ selectAll: string;
+ selectRow: (index: number) => string;
+}
+
export interface CopyColumn
- extends BaseColumn
- {
type: 'copy';
}
@@ -128,6 +134,16 @@ export interface DataTableProps
- {
onSortChange?: (sortConfig: DataTableSortConfig) => void;
/** Controlled sort state. Used with onSortChange for server-side sorting. */
sortConfig?: DataTableSortConfig;
+ /** Enable row selection with checkboxes. */
+ selectable?: boolean;
+ /** Controlled selected rows. */
+ selectedRows?: Item[];
+ /** Called when selection changes. */
+ onSelectedRowsChange?: (rows: Item[]) => void;
+ /** Derive a stable string ID from a row for selection tracking. */
+ getRowId?: (row: Item) => string;
+ /** Accessible labels for selection checkboxes. */
+ selectionLabels?: DataTableSelectionLabels;
}
const ALIGNMENT_CLASSES = {
@@ -187,6 +203,11 @@ const DEFAULT_COPY_LABELS: Required
= {
copiedTooltip: 'Copied!',
};
+const DEFAULT_SELECTION_LABELS: DataTableSelectionLabels = {
+ selectAll: 'Select all rows',
+ selectRow: (index: number) => `Select row ${index + 1}`,
+};
+
/**
* Copy button with clipboard functionality.
* @param props - Component props.
@@ -398,10 +419,17 @@ export function DataTable- ({
headerAlign = 'left',
onSortChange,
sortConfig,
+ selectable = false,
+ selectedRows,
+ onSelectedRowsChange,
+ getRowId,
+ selectionLabels,
}: DataTableProps
- ) {
const isServerSideSort = !!onSortChange;
+ const isControlledSelection = selectedRows !== undefined;
const [internalSorting, setInternalSorting] = useState
([]);
+ const [internalRowSelection, setInternalRowSelection] = useState({});
// Convert controlled sortConfig to TanStack SortingState for header display
const sorting: SortingState = useMemo(() => {
@@ -411,6 +439,22 @@ export function DataTable- ({
return internalSorting;
}, [isServerSideSort, sortConfig, internalSorting]);
+ const rowSelection = useMemo
(() => {
+ if (!selectable) return {};
+ if (isControlledSelection && selectedRows) {
+ if (getRowId) {
+ return Object.fromEntries(selectedRows.map((row) => [getRowId(row), true]));
+ }
+ return Object.fromEntries(
+ data.reduce<[string, boolean][]>((acc, item, idx) => {
+ if (selectedRows.includes(item)) acc.push([String(idx), true]);
+ return acc;
+ }, []),
+ );
+ }
+ return internalRowSelection;
+ }, [selectable, isControlledSelection, selectedRows, getRowId, data, internalRowSelection]);
+
const handleSortingChange = React.useCallback(
(updater: SortingState | ((old: SortingState) => SortingState)) => {
const newSorting = typeof updater === 'function' ? updater(sorting) : updater;
@@ -429,12 +473,70 @@ export function DataTable- ({
[isServerSideSort, onSortChange, sorting],
);
+ const handleRowSelectionChange = React.useCallback(
+ (updater: RowSelectionState | ((old: RowSelectionState) => RowSelectionState)) => {
+ const newSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
+ if (!isControlledSelection) setInternalRowSelection(newSelection);
+ if (onSelectedRowsChange) {
+ const items = getRowId
+ ? data.filter((item) => newSelection[getRowId(item)])
+ : data.filter((_, idx) => newSelection[String(idx)]);
+ onSelectedRowsChange(items);
+ }
+ },
+ [rowSelection, isControlledSelection, onSelectedRowsChange, getRowId, data],
+ );
+
+ const selectionColumn = useMemo
>(
+ () => ({
+ id: '__selection__',
+ enableSorting: false,
+ size: 48,
+ meta: {
+ headerAlign: 'left' as AlignmentType,
+ column: {
+ type: 'actions',
+ title: '',
+ width: '48px',
+ enableSorting: false,
+ render: () => null,
+ } as unknown as Column- ,
+ },
+ header: ({ table: t }) => (
+
t.toggleAllPageRowsSelected(!!checked)}
+ aria-label={selectionLabels?.selectAll ?? DEFAULT_SELECTION_LABELS.selectAll}
+ onClick={(e: React.MouseEvent) => e.stopPropagation()}
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(!!checked)}
+ aria-label={
+ selectionLabels?.selectRow(row.index) ?? DEFAULT_SELECTION_LABELS.selectRow(row.index)
+ }
+ onClick={(e: React.MouseEvent) => e.stopPropagation()}
+ />
+ ),
+ }),
+ [selectionLabels],
+ );
+
const tableColumns = useMemo[]>(() => {
- return columns.map((column, index) => {
+ const dataCols: ColumnDef- [] = columns.map((column, index) => {
return {
id: column.accessorKey ? String(column.accessorKey) : `column-${index}`,
accessorKey: column.accessorKey as string,
- header: column.title,
+ header:
+ typeof column.title === 'string' ? column.title : () => column.title as React.ReactNode,
size: column.width
? isNaN(Number(column.width))
? undefined
@@ -485,15 +587,20 @@ export function DataTable
- ({
},
};
});
- }, [columns, headerAlign]);
+ return selectable ? [selectionColumn, ...dataCols] : dataCols;
+ }, [columns, headerAlign, selectable, selectionColumn]);
const table = useReactTable({
data,
columns: tableColumns,
state: {
sorting,
+ ...(selectable && { rowSelection }),
},
+ getRowId: getRowId,
onSortingChange: handleSortingChange,
+ ...(selectable && { onRowSelectionChange: handleRowSelectionChange }),
+ enableRowSelection: selectable,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: isServerSideSort ? undefined : getSortedRowModel(),
manualSorting: isServerSideSort,
@@ -549,7 +656,7 @@ export function DataTable
- ({
{table.getRowModel().rows.length === 0 ? (
-
+
) {
+ return (
+
+ );
+}
+
+function AvatarImage({ className, ...props }: React.ComponentProps<'img'>) {
+ return (
+
+ );
+}
+
+function AvatarFallback({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarImage, AvatarFallback };
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/search.tsx b/packages/react/src/components/ui/search.tsx
new file mode 100644
index 000000000..bb935187d
--- /dev/null
+++ b/packages/react/src/components/ui/search.tsx
@@ -0,0 +1,131 @@
+/**
+ * Search component.
+ * @module search
+ * @internal
+ */
+
+'use client';
+
+import type { VariantProps } from 'class-variance-authority';
+import { cva } from 'class-variance-authority';
+import { SearchIcon, XIcon } from 'lucide-react';
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+const searchVariants = cva(
+ 'bg-input aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive theme-default:active:scale-[0.99] relative box-border inline-flex w-full shrink-0 cursor-text items-center justify-center gap-2 overflow-hidden rounded-2xl text-sm transition-[color,box-shadow] duration-150 ease-in-out outline-none disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-border/50 text-input-foreground shadow-input-resting hover:shadow-input-hover hover:border-primary/25 focus-within:border-border focus-within:ring-ring focus-within:ring-4',
+ error:
+ 'bg-destructive/25 border-destructive-border/50 text-destructive-foreground shadow-input-destructive-resting hover:shadow-input-destructive-hover hover:border-destructive-border/25 focus-within:ring-destructive-border/15 focus-within:ring-4',
+ },
+ size: {
+ default: 'h-10',
+ sm: 'h-9',
+ lg: 'h-11',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+export interface SearchProps
+ extends Omit, 'size' | 'onChange' | 'defaultValue'> {
+ error?: boolean;
+ size?: VariantProps['size'];
+ variant?: VariantProps['variant'];
+ value?: string;
+ defaultValue?: string;
+ onChange?: (value: string) => void;
+ onKeyDown?: (event: React.KeyboardEvent) => void;
+ onClear?: () => void;
+}
+
+function Search({
+ className,
+ variant,
+ size,
+ error,
+ value,
+ defaultValue,
+ onChange,
+ onKeyDown,
+ onClear,
+ placeholder = 'Search...',
+ disabled,
+ ...props
+}: SearchProps) {
+ const [internalValue, setInternalValue] = React.useState(defaultValue ?? '');
+ const inputRef = React.useRef(null);
+
+ const isControlled = value !== undefined;
+ const currentValue = isControlled ? value : internalValue;
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const newValue = e.target.value;
+ if (!isControlled) {
+ setInternalValue(newValue);
+ }
+ onChange?.(newValue);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ onKeyDown?.(e);
+ };
+
+ const handleClear = () => {
+ if (!isControlled) {
+ setInternalValue('');
+ }
+ onChange?.('');
+ onClear?.();
+ inputRef.current?.focus();
+ };
+
+ return (
+
+
+
+ {currentValue && !disabled && (
+
+
+
+ )}
+
+ );
+}
+
+export { Search, searchVariants };
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({
,
+): UseMemberManagementServiceOptions => ({
+ customMessages: {},
+ activeTab: 'invitations',
+ invitationParams: {
+ pageSize: 10,
+ fromToken: undefined,
+ sortConfig: { key: null, direction: 'asc' },
+ filters: {},
+ },
+ memberParams: {
+ pageSize: 10,
+ fromToken: undefined,
+ sortConfig: { key: null, direction: 'asc' },
+ filters: {},
+ },
+ ...overrides,
+});
+
+const renderService = (options: UseMemberManagementServiceOptions) => {
+ const { wrapper, queryClient } = createTestQueryClientWrapper();
+ return {
+ queryClient,
+ ...renderHook(() => useMemberManagementService(options), { wrapper }),
+ };
+};
+
+describe('useMemberManagementService', () => {
+ let mockCoreClient: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockCoreClient = initMockCoreClient();
+
+ vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({
+ coreClient: mockCoreClient,
+ });
+
+ vi.spyOn(useTranslatorModule, 'useTranslator').mockReturnValue({
+ t: createMockI18nService().translator('member_management'),
+ changeLanguage: vi.fn(),
+ currentLanguage: 'en',
+ fallbackLanguage: 'en',
+ });
+ });
+
+ describe('memberManagementQueryKeys', () => {
+ it('should have correct base key', () => {
+ expect(memberManagementQueryKeys.all).toEqual(['member-management']);
+ });
+
+ it('should have correct invitations key', () => {
+ expect(memberManagementQueryKeys.invitations()).toEqual(['member-management', 'invitations']);
+ });
+ });
+
+ describe('providersQuery', () => {
+ it('should fetch identity providers when invitations tab is active', async () => {
+ const options = createDefaultOptions({ activeTab: 'invitations' });
+ const { result } = renderService(options);
+
+ await waitFor(() => {
+ expect(result.current.providersQuery.isSuccess).toBe(true);
+ });
+
+ expect(
+ mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list,
+ ).toHaveBeenCalled();
+ });
+
+ 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' });
+ const { result } = renderService(options);
+
+ await waitFor(() => {
+ expect(result.current.invitationsQuery.isSuccess).toBe(true);
+ });
+
+ expect(
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.list,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ take: 10,
+ from: undefined,
+ sort: undefined,
+ }),
+ );
+ });
+
+ it('should not fetch invitations when members tab is active', () => {
+ const options = createDefaultOptions({ activeTab: 'members' });
+ const { result } = renderService(options);
+
+ expect(result.current.invitationsQuery.fetchStatus).toBe('idle');
+ });
+
+ it('should pass sort parameter when sort config has a valid key', async () => {
+ const options = createDefaultOptions({
+ invitationParams: {
+ pageSize: 10,
+ fromToken: undefined,
+ sortConfig: { key: 'created_at', direction: 'desc' },
+ filters: {},
+ },
+ });
+ const { result } = renderService(options);
+
+ await waitFor(() => {
+ expect(result.current.invitationsQuery.isSuccess).toBe(true);
+ });
+
+ expect(
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.list,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sort: 'created_at:-1',
+ }),
+ );
+ });
+
+ it('should pass fromToken when provided', async () => {
+ const options = createDefaultOptions({
+ invitationParams: {
+ pageSize: 10,
+ fromToken: 'token_abc',
+ sortConfig: { key: null, direction: 'asc' },
+ filters: {},
+ },
+ });
+ const { result } = renderService(options);
+
+ await waitFor(() => {
+ expect(result.current.invitationsQuery.isSuccess).toBe(true);
+ });
+
+ expect(
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.list,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ from: 'token_abc',
+ }),
+ );
+ });
+
+ it('should return parsed invitations data', async () => {
+ const mockInvitation = createMockInvitation();
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.list = vi
+ .fn()
+ .mockResolvedValue({
+ data: [mockInvitation],
+ response: { next: 'next_token' },
+ });
+
+ const options = createDefaultOptions();
+ const { result } = renderService(options);
+
+ await waitFor(() => {
+ expect(result.current.invitationsQuery.isSuccess).toBe(true);
+ });
+
+ expect(result.current.invitationsQuery.data).toEqual({
+ invitations: [mockInvitation],
+ next: 'next_token',
+ });
+ });
+ });
+
+ describe('createInvitationMutation', () => {
+ it('should create an invitation and show success toast', async () => {
+ const options = createDefaultOptions();
+ const { result } = renderService(options);
+
+ await act(async () => {
+ result.current.createInvitationMutation.mutate({
+ invitees: [{ email: 'new@example.com', roles: ['role_admin'] }],
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.createInvitationMutation.isSuccess).toBe(true);
+ });
+
+ expect(
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.create,
+ ).toHaveBeenCalled();
+ expect(mockedShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
+ });
+
+ it('should call onBefore action and cancel if it returns false', async () => {
+ const onBefore = vi.fn().mockReturnValue(false);
+ const options = createDefaultOptions({
+ createInvitationAction: { onBefore },
+ });
+ const { result } = renderService(options);
+
+ await act(async () => {
+ result.current.createInvitationMutation.mutate({
+ invitees: [{ email: 'new@example.com' }],
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.createInvitationMutation.isError).toBe(true);
+ });
+
+ expect(onBefore).toHaveBeenCalled();
+ expect(
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.create,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('should call onAfter action on success', async () => {
+ const onAfter = vi.fn();
+ const options = createDefaultOptions({
+ createInvitationAction: { onAfter },
+ });
+ const { result } = renderService(options);
+
+ await act(async () => {
+ result.current.createInvitationMutation.mutate({
+ invitees: [{ email: 'new@example.com' }],
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.createInvitationMutation.isSuccess).toBe(true);
+ });
+
+ expect(onAfter).toHaveBeenCalled();
+ });
+
+ it('should show error toast on failure', async () => {
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.create = vi
+ .fn()
+ .mockRejectedValue(new Error('Create failed'));
+
+ const options = createDefaultOptions();
+ const { result } = renderService(options);
+
+ await act(async () => {
+ result.current.createInvitationMutation.mutate({
+ invitees: [{ email: 'new@example.com' }],
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.createInvitationMutation.isError).toBe(true);
+ });
+
+ expect(mockedShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
+ });
+ });
+
+ describe('revokeInvitationMutation', () => {
+ it('should revoke an invitation and show success toast', async () => {
+ const invitation = createMockInvitation();
+ const options = createDefaultOptions();
+ const { result } = renderService(options);
+
+ await act(async () => {
+ result.current.revokeInvitationMutation.mutate(invitation);
+ });
+
+ await waitFor(() => {
+ expect(result.current.revokeInvitationMutation.isSuccess).toBe(true);
+ });
+
+ expect(
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.delete,
+ ).toHaveBeenCalledWith(invitation.id);
+ expect(mockedShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
+ });
+
+ it('should call onBefore action and cancel if it returns false', async () => {
+ const onBefore = vi.fn().mockReturnValue(false);
+ const invitation = createMockInvitation();
+ const options = createDefaultOptions({
+ revokeInvitationAction: { onBefore },
+ });
+ const { result } = renderService(options);
+
+ await act(async () => {
+ result.current.revokeInvitationMutation.mutate(invitation);
+ });
+
+ await waitFor(() => {
+ expect(result.current.revokeInvitationMutation.isError).toBe(true);
+ });
+
+ expect(onBefore).toHaveBeenCalledWith(invitation);
+ expect(
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.delete,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('should show error toast on failure', async () => {
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.delete = vi
+ .fn()
+ .mockRejectedValue(new Error('Revoke failed'));
+
+ const invitation = createMockInvitation();
+ const options = createDefaultOptions();
+ const { result } = renderService(options);
+
+ await act(async () => {
+ result.current.revokeInvitationMutation.mutate(invitation);
+ });
+
+ await waitFor(() => {
+ expect(result.current.revokeInvitationMutation.isError).toBe(true);
+ });
+
+ expect(mockedShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
+ });
+ });
+
+ describe('resendInvitationMutation', () => {
+ it('should revoke and resend an invitation', async () => {
+ const invitation = createMockInvitation();
+ const options = createDefaultOptions();
+ const { result } = renderService(options);
+
+ await act(async () => {
+ result.current.resendInvitationMutation.mutate(invitation);
+ });
+
+ await waitFor(() => {
+ 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(mockedShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
+ });
+
+ it('should call onBefore action and cancel if it returns false', async () => {
+ const onBefore = vi.fn().mockReturnValue(false);
+ const invitation = createMockInvitation();
+ const options = createDefaultOptions({
+ resendInvitationAction: { onBefore },
+ });
+ const { result } = renderService(options);
+
+ await act(async () => {
+ result.current.resendInvitationMutation.mutate(invitation);
+ });
+
+ await waitFor(() => {
+ expect(result.current.resendInvitationMutation.isError).toBe(true);
+ });
+
+ expect(onBefore).toHaveBeenCalledWith(invitation);
+ });
+
+ it('should show error toast on failure', async () => {
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.get = vi
+ .fn()
+ .mockRejectedValue(new Error('Fetch failed'));
+
+ const invitation = createMockInvitation();
+ 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(mockedShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }));
+ });
+ });
+
+ describe('fetchInvitationDetails', () => {
+ it('should fetch invitation details by id', async () => {
+ const mockInvitation = createMockInvitation();
+ const options = createDefaultOptions();
+ const { result } = renderService(options);
+
+ const details = await result.current.fetchInvitationDetails('inv_abc123xyz456');
+
+ expect(
+ mockCoreClient.getMyOrganizationApiClient().organization.invitations.get,
+ ).toHaveBeenCalledWith('inv_abc123xyz456');
+ expect(details).toEqual(mockInvitation);
+ });
+ });
+});
diff --git a/packages/react/src/hooks/my-organization/shared/services/use-member-detail-service.ts b/packages/react/src/hooks/my-organization/shared/services/use-member-detail-service.ts
new file mode 100644
index 000000000..3fbdb6617
--- /dev/null
+++ b/packages/react/src/hooks/my-organization/shared/services/use-member-detail-service.ts
@@ -0,0 +1,172 @@
+/**
+ * Member detail service hook.
+ * @module use-member-detail-service
+ * @internal
+ */
+
+import {
+ memberDetailQueryKeys,
+ memberManagementQueryKeys,
+ OrganizationDetailsMappers,
+ type Role,
+} from '@auth0/universal-components-core';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+
+import { showToast } from '@/components/auth0/shared/toast';
+import { useMemberManagementService } from '@/hooks/my-organization/shared/services/use-member-management-service';
+import { useCoreClient } from '@/hooks/shared/use-core-client';
+import { useErrorHandler } from '@/hooks/shared/use-error-handler';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type {
+ MemberDetailServiceResult,
+ UseMemberDetailServiceOptions,
+} from '@/types/my-organization/member-management/organization-member-detail-types';
+
+/**
+ * Service hook for member detail API operations.
+ * @param options - Service configuration options.
+ * @returns Query and mutation objects for member detail.
+ */
+export function useMemberDetailService(
+ options: UseMemberDetailServiceOptions,
+): MemberDetailServiceResult {
+ const {
+ userId,
+ customMessages = {},
+ removeFromOrgAction,
+ assignRolesAction,
+ removeRolesAction,
+ } = options;
+
+ const { coreClient } = useCoreClient();
+ const { t } = useTranslator('member_management', customMessages);
+ const handleError = useErrorHandler();
+ const queryClient = useQueryClient();
+
+ const isValidUserId = !!userId && /^[^|]+\|[^|]+$/.test(userId);
+
+ const memberQuery = useQuery({
+ queryKey: memberDetailQueryKeys.member(userId),
+ queryFn: () => coreClient!.getMyOrganizationApiClient().organization.members.get(userId),
+ enabled: !!coreClient && isValidUserId,
+ });
+
+ const memberRolesQuery = useQuery({
+ queryKey: memberDetailQueryKeys.memberRoles(userId),
+ queryFn: async () => {
+ const response = await coreClient!
+ .getMyOrganizationApiClient()
+ .organization.members.roles.list(userId);
+ return response.data;
+ },
+ enabled: !!coreClient && isValidUserId && memberQuery.isSuccess,
+ });
+
+ const { rolesQuery } = useMemberManagementService({});
+
+ const organizationQuery = useQuery({
+ queryKey: memberDetailQueryKeys.organization,
+ queryFn: async () => {
+ const response = await coreClient!.getMyOrganizationApiClient().organizationDetails.get();
+ return OrganizationDetailsMappers.fromAPI(response);
+ },
+ enabled: !!coreClient,
+ });
+
+ const removeFromOrgMutation = useMutation({
+ mutationFn: async (_args: { memberId?: string; memberName?: string; orgName?: string }) => {
+ if (!userId) throw new Error('userId is required');
+ if (removeFromOrgAction?.onBefore && !removeFromOrgAction.onBefore(userId)) {
+ throw new Error('Remove from org cancelled by onBefore');
+ }
+ await coreClient!
+ .getMyOrganizationApiClient()
+ .organization.memberships.deleteMemberships({ members: [userId] });
+ },
+ onSuccess: (_, { memberName, orgName }) => {
+ removeFromOrgAction?.onAfter?.(userId);
+ showToast({
+ type: 'success',
+ message: t('member.detail.actions.remove_from_org.success', {
+ memberName: memberName,
+ orgName: orgName,
+ }),
+ });
+ queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.members() });
+ },
+ onError: (error) => {
+ handleError(error, { fallbackMessage: t('member.detail.error.remove_from_org_failed') });
+ },
+ });
+
+ const assignRolesMutation = useMutation({
+ mutationFn: async (roleIds: string[]) => {
+ if (!userId) throw new Error('userId is required');
+ if (assignRolesAction?.onBefore && !assignRolesAction.onBefore({ userId, roleIds })) {
+ throw new Error('Assign roles cancelled by onBefore');
+ }
+ await coreClient!
+ .getMyOrganizationApiClient()
+ .organization.members.roles.assign(userId, { role_ids: roleIds });
+ assignRolesAction?.onAfter?.({ userId, roleIds });
+ },
+ onSuccess: (_, roleIds) => {
+ const allRoles = queryClient.getQueryData(memberManagementQueryKeys.roles()) ?? [];
+ const newRoles = allRoles.filter((r) => roleIds.includes(r.id));
+ queryClient.setQueryData(memberDetailQueryKeys.memberRoles(userId), (old) => [
+ ...(old ?? []),
+ ...newRoles,
+ ]);
+ const assignKey =
+ roleIds.length === 1
+ ? 'member.detail.roles.assign_modal.success'
+ : 'member.detail.roles.assign_modal.success_plural';
+ showToast({ type: 'success', message: t(assignKey) });
+ queryClient.invalidateQueries({ queryKey: memberDetailQueryKeys.memberRoles(userId) });
+ },
+ onError: (error) => {
+ handleError(error, { fallbackMessage: t('member.detail.error.assign_role_failed') });
+ },
+ });
+
+ const removeRolesMutation = useMutation({
+ mutationFn: async (roles: Role[]) => {
+ if (!userId) throw new Error('userId is required');
+ const roleIds = roles.map((r) => r.id);
+ if (removeRolesAction?.onBefore && !removeRolesAction.onBefore({ userId, roleIds })) {
+ throw new Error('Remove roles cancelled by onBefore');
+ }
+ await coreClient!
+ .getMyOrganizationApiClient()
+ .organization.members.roles.unassign(userId, { role_ids: roleIds });
+ removeRolesAction?.onAfter?.({ userId, roleIds });
+ },
+ onSuccess: (_, roles) => {
+ const removedIds = new Set(roles.map((r) => r.id));
+ queryClient.setQueryData(memberDetailQueryKeys.memberRoles(userId), (old) =>
+ (old ?? []).filter((r) => !removedIds.has(r.id)),
+ );
+ const isSingle = roles.length === 1;
+ const message = isSingle
+ ? t('member.detail.roles.remove_confirm.success', { roleName: roles[0]?.name })
+ : t('member.detail.roles.remove_confirm.success_plural', {
+ roleNames: roles.map((r) => `"${r.name}"`).join(', '),
+ });
+ showToast({ type: 'success', message });
+ queryClient.invalidateQueries({ queryKey: memberDetailQueryKeys.memberRoles(userId) });
+ },
+ onError: (error) => {
+ handleError(error, { fallbackMessage: t('member.detail.error.remove_role_failed') });
+ },
+ });
+
+ return {
+ memberQuery,
+ memberRolesQuery,
+ rolesQuery,
+ organizationQuery,
+ removeFromOrgMutation,
+ assignRolesMutation,
+ removeRolesMutation,
+ };
+}
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..05a58795f
--- /dev/null
+++ b/packages/react/src/hooks/my-organization/shared/services/use-member-management-service.ts
@@ -0,0 +1,324 @@
+/**
+ * Member management service hook.
+ * @module use-member-management-service
+ * @internal
+ */
+
+import type { OrgMember, Role } from '@auth0/universal-components-core';
+import {
+ type MemberInvitation,
+ type ListIdentityProvidersResponseContent,
+ memberManagementQueryKeys,
+ OrganizationDetailsMappers,
+} from '@auth0/universal-components-core';
+import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
+import React from 'react';
+
+import { showToast } from '@/components/auth0/shared/toast';
+import { useCoreClient } from '@/hooks/shared/use-core-client';
+import { useErrorHandler } from '@/hooks/shared/use-error-handler';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type { CreateInvitationInput } from '@/types/my-organization/member-management/organization-invitation-table-types';
+import type {
+ UseMemberManagementServiceOptions,
+ MemberManagementServiceResult,
+ MemberManagementSortConfig,
+} from '@/types/my-organization/member-management/organization-member-management-types';
+
+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: MemberManagementSortConfig): 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,
+ memberParams,
+ assignRolesAction,
+ removeFromOrgAction,
+ } = options;
+
+ const isInvitationsTabActive = activeTab === 'invitations';
+ const isActiveTabProvided = !!activeTab;
+
+ const { coreClient } = useCoreClient();
+ const { t } = useTranslator('member_management', customMessages);
+ const handleError = useErrorHandler();
+ 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 && isActiveTabProvided,
+ });
+
+ 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({
+ 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;
+
+ return { invitations, next };
+ },
+ enabled: !!coreClient && isInvitationsTabActive && !!invitationParams,
+ placeholderData: keepPreviousData,
+ });
+
+ const membersQuery = useQuery({
+ queryKey: [
+ ...memberManagementQueryKeys.members(),
+ memberParams?.pageSize,
+ memberParams?.fromToken,
+ ],
+ queryFn: async () => {
+ const page = await coreClient!.getMyOrganizationApiClient().organization.members.list({
+ take: memberParams!.pageSize,
+ from: memberParams!.fromToken,
+ });
+ const members: OrgMember[] = page.data;
+ const next = members.length < memberParams!.pageSize ? null : page.response.next;
+ return { members, next };
+ },
+ enabled: !!coreClient && !isInvitationsTabActive && !!memberParams,
+ placeholderData: keepPreviousData,
+ });
+
+ const organizationQuery = useQuery({
+ queryKey: memberManagementQueryKeys.organization,
+ queryFn: async () => {
+ const response = await coreClient!.getMyOrganizationApiClient().organizationDetails.get();
+ return OrganizationDetailsMappers.fromAPI(response);
+ },
+ enabled: !!coreClient,
+ });
+
+ const assignRolesMutation = useMutation({
+ mutationFn: async ({ roleIds, userId }: { roleIds: string[]; userId?: string | null }) => {
+ if (!userId) throw new Error('userId is required');
+ if (assignRolesAction?.onBefore && !assignRolesAction.onBefore({ userId, roleIds })) {
+ throw new Error('Assign roles cancelled by onBefore');
+ }
+ await coreClient!
+ .getMyOrganizationApiClient()
+ .organization.members.roles.assign(userId, { role_ids: roleIds });
+ assignRolesAction?.onAfter?.({ userId, roleIds });
+ },
+ onSuccess: (_, { roleIds, userId }) => {
+ if (!userId) return;
+ const allRoles = queryClient.getQueryData(memberManagementQueryKeys.roles()) ?? [];
+ const newRoles = allRoles.filter((r) => roleIds.includes(r.id));
+ queryClient.setQueryData(memberManagementQueryKeys.memberRoles(userId), (old) => [
+ ...(old ?? []),
+ ...newRoles,
+ ]);
+ const assignKey =
+ roleIds.length === 1
+ ? 'member.detail.roles.assign_modal.success'
+ : 'member.detail.roles.assign_modal.success_plural';
+ showToast({ type: 'success', message: t(assignKey) });
+ queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.all });
+ },
+ onError: (error) => {
+ handleError(error, { fallbackMessage: t('member.detail.error.assign_role_failed') });
+ },
+ });
+
+ const removeFromOrgMutation = useMutation({
+ mutationFn: async ({
+ userId,
+ }: {
+ userId?: string | null;
+ memberName?: string;
+ orgName?: string;
+ }) => {
+ if (!userId) throw new Error('userId is required');
+ if (removeFromOrgAction?.onBefore && !removeFromOrgAction.onBefore(userId)) {
+ throw new Error('Remove from org cancelled by onBefore');
+ }
+ await coreClient!
+ .getMyOrganizationApiClient()
+ .organization.memberships.deleteMemberships({ members: [userId] });
+ },
+ onSuccess: (_, { userId, memberName, orgName }) => {
+ if (!userId) return;
+ removeFromOrgAction?.onAfter?.(userId);
+ showToast({
+ type: 'success',
+ message: t('member.detail.actions.remove_from_org.success', {
+ memberName: memberName ?? '',
+ orgName: orgName ?? '',
+ }),
+ });
+ queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.members() });
+ },
+ onError: (error) => {
+ handleError(error, { fallbackMessage: t('member.detail.error.remove_from_org_failed') });
+ },
+ });
+
+ 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,
+ 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);
+ 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) => {
+ handleError(error, { fallbackMessage: 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', { email: invitation.invitee?.email ?? '' }),
+ });
+ queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() });
+ },
+ onError: (error) => {
+ handleError(error, { fallbackMessage: 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', {
+ email: invitation.invitee?.email ?? '',
+ }),
+ });
+ queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() });
+ },
+ onError: (error) => {
+ handleError(error, { fallbackMessage: 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,
+ rolesQuery,
+ invitationsQuery,
+ organizationQuery,
+ membersQuery,
+ assignRolesMutation,
+ removeFromOrgMutation,
+ 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..f3db4455f 100644
--- a/packages/react/src/hooks/my-organization/use-config.ts
+++ b/packages/react/src/hooks/my-organization/use-config.ts
@@ -58,6 +58,5 @@ export function useConfig(): UseConfigResult {
filteredStrategies,
shouldAllowDeletion,
isConfigValid,
- allowedRoles: [],
};
}
diff --git a/packages/react/src/hooks/my-organization/use-member-detail.ts b/packages/react/src/hooks/my-organization/use-member-detail.ts
new file mode 100644
index 000000000..be0a5186b
--- /dev/null
+++ b/packages/react/src/hooks/my-organization/use-member-detail.ts
@@ -0,0 +1,172 @@
+/**
+ * Organization member detail hook.
+ * @module use-member-detail
+ */
+
+import { resolveErrorMessage, type Role } from '@auth0/universal-components-core';
+import * as React from 'react';
+
+import { useMemberDetailService } from '@/hooks/my-organization/shared/services/use-member-detail-service';
+import { useErrorHandler } from '@/hooks/shared/use-error-handler';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type {
+ MemberDetailModalState,
+ MemberDetailTab,
+ UseOrganizationMemberDetailOptions,
+ UseOrganizationMemberDetailResult,
+} from '@/types/my-organization/member-management/organization-member-detail-types';
+
+/**
+ * Hook for organization member detail page.
+ * @param options - Hook configuration options.
+ * @returns State and handler functions.
+ */
+export function useOrganizationMemberDetail(
+ options: UseOrganizationMemberDetailOptions,
+): UseOrganizationMemberDetailResult {
+ const {
+ userId,
+ onBack,
+ customMessages = {},
+ readOnly = false,
+ removeFromOrgAction,
+ assignRolesAction,
+ removeRolesAction,
+ } = options;
+
+ const {
+ memberQuery,
+ memberRolesQuery,
+ rolesQuery,
+ organizationQuery,
+ removeFromOrgMutation,
+ assignRolesMutation,
+ removeRolesMutation,
+ } = useMemberDetailService({
+ userId,
+ customMessages,
+ removeFromOrgAction,
+ assignRolesAction,
+ removeRolesAction,
+ });
+
+ const { t } = useTranslator('member_management', customMessages);
+ const handleError = useErrorHandler();
+ const hasShownMemberRolesError = React.useRef(false);
+
+ React.useEffect(() => {
+ if (memberRolesQuery.isError && !hasShownMemberRolesError.current) {
+ handleError(memberRolesQuery.error, {
+ fallbackMessage: t('member.detail.error.fetch_roles_failed'),
+ });
+ hasShownMemberRolesError.current = true;
+ }
+ if (!memberRolesQuery.isError) {
+ hasShownMemberRolesError.current = false;
+ }
+ }, [memberRolesQuery.isError, memberRolesQuery.error, handleError, t]);
+
+ const [activeTab, setActiveTab] = React.useState('details');
+ const [modalState, setModalState] = React.useState({ type: null });
+ const [selectedRoles, setSelectedRoles] = React.useState([]);
+
+ const handleBack = React.useCallback(() => {
+ onBack?.();
+ }, [onBack]);
+
+ const openModal = React.useCallback(
+ (state: MemberDetailModalState) => {
+ if (readOnly && state.type !== null) return;
+ setModalState(state);
+ },
+ [readOnly],
+ );
+
+ const closeModal = React.useCallback(() => {
+ setModalState({ type: null });
+ }, []);
+
+ const handleRemoveFromOrgConfirm = React.useCallback(
+ (userId?: string, memberName?: string, orgName?: string) => {
+ removeFromOrgMutation.mutate(
+ { userId, memberName, orgName },
+ {
+ onSuccess: () => {
+ closeModal();
+ onBack?.();
+ },
+ },
+ );
+ },
+ [removeFromOrgMutation, closeModal, onBack],
+ );
+
+ const handleAssignRolesSubmit = React.useCallback(
+ (roleIds: string[]) => {
+ assignRolesMutation.mutate(roleIds, {
+ onSuccess: () => {
+ closeModal();
+ },
+ });
+ },
+ [assignRolesMutation, closeModal],
+ );
+
+ const handleRemoveRolesCancel = React.useCallback(() => {
+ setSelectedRoles([]);
+ closeModal();
+ }, [closeModal]);
+
+ const handleRemoveRolesConfirm = React.useCallback(() => {
+ if (modalState.type !== 'removeRoles') return;
+ removeRolesMutation.mutate(modalState.roles, {
+ onSuccess: () => {
+ setSelectedRoles([]);
+ closeModal();
+ },
+ });
+ }, [modalState, removeRolesMutation, closeModal]);
+
+ const member = memberQuery.data ?? null;
+ const orgDisplayName = organizationQuery.data?.display_name ?? '';
+ const memberRoles: Role[] = memberRolesQuery.data ?? [];
+ const availableRoles: Role[] = React.useMemo(() => {
+ const assignedIds = new Set(memberRoles.map((r) => r.id));
+ return (rolesQuery.data ?? []).filter((r) => !assignedIds.has(r.id));
+ }, [rolesQuery.data, memberRoles]);
+
+ const removingRoles = modalState.type === 'removeRoles' ? modalState.roles : [];
+
+ const memberErrorMessage = memberQuery.isError
+ ? resolveErrorMessage(memberQuery.error, t('member.detail.error.fetch_failed'))
+ : null;
+
+ return {
+ activeTab,
+ member,
+ orgDisplayName,
+ memberRoles,
+ availableRoles,
+ selectedRoles,
+ memberError: memberErrorMessage,
+ isFetchingMember: memberQuery.isLoading || memberQuery.isFetching,
+ isFetchingMemberRoles: memberRolesQuery.isLoading,
+ isFetchingAvailableRoles: rolesQuery.isLoading || rolesQuery.isFetching,
+ isLoading: memberQuery.isLoading,
+ isRemovingFromOrg: removeFromOrgMutation.isPending,
+ isAssigningRoles: assignRolesMutation.isPending,
+ isRemovingRoles: removeRolesMutation.isPending,
+ removingRoleIds: removeRolesMutation.isPending ? removingRoles.map((r) => r.id) : [],
+ modalState,
+
+ setActiveTab,
+ setSelectedRoles,
+ handleBack,
+ openModal,
+ closeModal,
+ handleRemoveFromOrgConfirm,
+ handleAssignRolesSubmit,
+ handleRemoveRolesCancel,
+ handleRemoveRolesConfirm,
+ };
+}
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..e0d372930
--- /dev/null
+++ b/packages/react/src/hooks/my-organization/use-organization-member-management.ts
@@ -0,0 +1,314 @@
+/**
+ * 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 { useCheckpointPagination } from '@/hooks/shared/use-checkpoint-pagination';
+import { useTranslator } from '@/hooks/shared/use-translator';
+import type {
+ CreateInvitationInput,
+ IdentityProviderOption,
+} from '@/types/my-organization/member-management/organization-invitation-table-types';
+import type {
+ ActiveTab,
+ MemberManagementModalState,
+ MemberManagementFilterState,
+ MemberManagementSortConfig,
+ UseOrganizationMemberManagementOptions,
+ UseOrganizationMemberManagementResult,
+} from '@/types/my-organization/member-management/organization-member-management-types';
+
+/**
+ * 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,
+ viewMemberDetailsAction,
+ assignRolesAction,
+ removeFromOrgAction,
+ } = options;
+
+ const { t } = useTranslator('member_management', customMessages);
+
+ const [activeTab, setActiveTab] = React.useState('members');
+
+ /** Pagination and sorting for invitations */
+ const {
+ pageSize: invitationPageSize,
+ currentPage: invitationCurrentPage,
+ fromToken: invitationFromToken,
+ hasPreviousPage: invitationHasPreviousPage,
+ sortConfig: invitationSortConfig,
+ filters: invitationFilters,
+ goToNextPage: invitationGoToNextPage,
+ goToPreviousPage: invitationGoToPreviousPage,
+ changePageSize: invitationChangePageSize,
+ changeSortConfig: invitationChangeSortConfig,
+ changeFilters: invitationChangeFilters,
+ } = useCheckpointPagination();
+
+ /** Pagination and sorting for members */
+ const {
+ pageSize: memberPageSize,
+ currentPage: memberCurrentPage,
+ fromToken: memberFromToken,
+ hasPreviousPage: memberHasPreviousPage,
+ sortConfig: memberSortConfig,
+ filters: memberFilters,
+ goToNextPage: memberGoToNextPage,
+ goToPreviousPage: memberGoToPreviousPage,
+ changePageSize: memberChangePageSize,
+ changeSortConfig: memberChangeSortConfig,
+ changeFilters: memberChangeFilters,
+ } = useCheckpointPagination();
+
+ const [modalState, setModalState] = React.useState({ type: null });
+ const detailsRequestIdRef = React.useRef(0);
+
+ const {
+ providersQuery,
+ rolesQuery,
+ invitationsQuery,
+ membersQuery,
+ organizationQuery,
+ createInvitationMutation,
+ revokeInvitationMutation,
+ resendInvitationMutation,
+ assignRolesMutation,
+ removeFromOrgMutation,
+ fetchInvitationDetails,
+ } = useMemberManagementService({
+ customMessages,
+ activeTab,
+ createInvitationAction,
+ revokeInvitationAction,
+ resendInvitationAction,
+ invitationParams: {
+ pageSize: invitationPageSize,
+ fromToken: invitationFromToken,
+ sortConfig: invitationSortConfig,
+ filters: invitationFilters,
+ },
+ memberParams: {
+ pageSize: memberPageSize,
+ fromToken: memberFromToken,
+ sortConfig: memberSortConfig,
+ filters: memberFilters,
+ },
+ assignRolesAction,
+ removeFromOrgAction,
+ });
+
+ const availableProviders: IdentityProviderOption[] = providersQuery.data ?? [];
+ const availableRoles = rolesQuery.data ?? [];
+ const currentInvitations = invitationsQuery.data?.invitations ?? [];
+ const currentMembers = membersQuery.data?.members ?? [];
+ const invitationNextToken = invitationsQuery.data?.next ?? null;
+ const memberNextToken = membersQuery.data?.next ?? null;
+ const orgDisplayName = organizationQuery.data?.display_name ?? '';
+
+ 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;
+ await navigator.clipboard.writeText(invitation.invitation_url);
+ }, []);
+
+ const handleViewMemberDetails = React.useCallback(
+ (userId: string) => {
+ viewMemberDetailsAction?.onAfter?.(userId);
+ },
+ [viewMemberDetailsAction],
+ );
+
+ const handleAssignRolesSubmit = React.useCallback(
+ (roleIds: string[], userId?: string | null) => {
+ assignRolesMutation.mutate(
+ { roleIds, userId },
+ {
+ onSuccess: () => {
+ closeModal();
+ },
+ },
+ );
+ },
+ [assignRolesMutation, closeModal],
+ );
+
+ const handleRemoveFromOrgConfirm = React.useCallback(
+ (userId?: string | null, memberName?: string, orgName?: string) => {
+ removeFromOrgMutation.mutate(
+ { userId, memberName, orgName },
+ {
+ onSuccess: () => {
+ closeModal();
+ },
+ },
+ );
+ },
+ [removeFromOrgMutation, closeModal],
+ );
+
+ const handleNextPage = React.useCallback(() => {
+ if (activeTab === 'members' && memberNextToken) {
+ memberGoToNextPage(memberNextToken);
+ } else if (invitationNextToken) {
+ invitationGoToNextPage(invitationNextToken);
+ }
+ }, [activeTab, invitationNextToken, memberNextToken, invitationGoToNextPage, memberGoToNextPage]);
+
+ const handlePreviousPage = React.useCallback(() => {
+ if (activeTab === 'members') {
+ memberGoToPreviousPage();
+ } else {
+ invitationGoToPreviousPage();
+ }
+ }, [activeTab, invitationGoToPreviousPage, memberGoToPreviousPage]);
+
+ const handlePageSizeChange = React.useCallback(
+ (pageSize: number) => {
+ if (activeTab === 'members') {
+ memberChangePageSize(pageSize);
+ } else {
+ invitationChangePageSize(pageSize);
+ }
+ },
+ [activeTab, invitationChangePageSize, memberChangePageSize],
+ );
+
+ const handleSortChange = React.useCallback(
+ (sortConfig: MemberManagementSortConfig) => {
+ if (activeTab === 'members') {
+ memberChangeSortConfig(sortConfig);
+ } else {
+ invitationChangeSortConfig(sortConfig);
+ }
+ },
+ [activeTab, invitationChangeSortConfig, memberChangeSortConfig],
+ );
+
+ const handleRoleFilterChange = React.useCallback(
+ (roleId: string | undefined) => {
+ if (activeTab === 'members') {
+ memberChangeFilters((prev) => ({ ...prev, roleId }));
+ } else {
+ invitationChangeFilters((prev) => ({ ...prev, roleId }));
+ }
+ },
+ [activeTab, invitationChangeFilters, memberChangeFilters],
+ );
+
+ return {
+ activeTab,
+ availableRoles,
+ availableProviders,
+
+ invitations: currentInvitations,
+ members: currentMembers,
+ orgDisplayName: orgDisplayName,
+ isInitialLoading: invitationsQuery.isLoading || membersQuery.isLoading,
+ isFetchingInvitations: invitationsQuery.isFetching,
+ isFetchingMembers: membersQuery.isFetching,
+ isFetchingAvailableRoles: rolesQuery.isLoading || rolesQuery.isFetching,
+ isRemovingFromOrg: removeFromOrgMutation.isPending,
+ isAssigningRoles: assignRolesMutation.isPending,
+ isCreatingInvitation: createInvitationMutation.isPending,
+ isRevokingInvitation: revokeInvitationMutation.isPending,
+ isResendingInvitation: resendInvitationMutation.isPending,
+ invitationPagination: {
+ pageSize: invitationPageSize,
+ currentPage: invitationCurrentPage,
+ hasNextPage: !!invitationNextToken,
+ hasPreviousPage: invitationHasPreviousPage,
+ },
+ memberPagination: {
+ pageSize: memberPageSize,
+ currentPage: memberCurrentPage,
+ hasNextPage: !!memberNextToken,
+ hasPreviousPage: memberHasPreviousPage,
+ },
+ invitationFilters,
+ invitationSortConfig,
+ memberFilters,
+ memberSortConfig,
+ modalState,
+
+ setActiveTab,
+ openModal,
+ closeModal,
+ handleCreateSubmit,
+ handleRevokeConfirm,
+ handleRevokeResendConfirm,
+ handleCopyUrl,
+ handleNextPage,
+ handlePreviousPage,
+ handlePageSizeChange,
+ handleSortChange,
+ handleRoleFilterChange,
+ handleViewMemberDetails,
+ handleAssignRolesSubmit,
+ handleRemoveFromOrgConfirm,
+ };
+}
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/lib/constants/my-organization/member-management/member-management-constants.ts b/packages/react/src/lib/constants/my-organization/member-management/member-management-constants.ts
new file mode 100644
index 000000000..1e5dddfcc
--- /dev/null
+++ b/packages/react/src/lib/constants/my-organization/member-management/member-management-constants.ts
@@ -0,0 +1,6 @@
+/**
+ * Member management related constants for the My Organization section.
+ * @internal
+ */
+
+export const MEMBER_MANAGEMENT_PAGE_SIZE_OPTIONS = [10, 25, 50];
diff --git a/packages/react/src/lib/utils/my-organization/member-management/__tests__/member-management-utils.test.tsx b/packages/react/src/lib/utils/my-organization/member-management/__tests__/member-management-utils.test.tsx
new file mode 100644
index 000000000..f8b9744b4
--- /dev/null
+++ b/packages/react/src/lib/utils/my-organization/member-management/__tests__/member-management-utils.test.tsx
@@ -0,0 +1,155 @@
+import { describe, it, expect, vi } from 'vitest';
+
+import {
+ getInitials,
+ getInvitationStatus,
+ getMemberDisplayName,
+ getRelativeLastLoginLabel,
+} from '@/lib/utils/my-organization/member-management/member-management-utils';
+import { createMockMember } from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks';
+
+describe('getInvitationStatus', () => {
+ it('returns expired when invitation has expired', () => {
+ const expired = getInvitationStatus({ expires_at: '2000-01-01T00:00:00Z' } as never);
+ expect(expired).toBe('expired');
+ });
+
+ it('returns pending when invitation has not expired', () => {
+ const pending = getInvitationStatus({ expires_at: '2099-01-01T00:00:00Z' } as never);
+ expect(pending).toBe('pending');
+ });
+});
+
+describe('getInitials', () => {
+ it('returns single uppercase letter for a single-word name', () => {
+ expect(getInitials('Alice')).toBe('A');
+ });
+
+ it('returns first and last initials for a multi-word name', () => {
+ expect(getInitials('Alice Wonderland')).toBe('AW');
+ });
+
+ it('uses first and last word for names with more than two parts', () => {
+ expect(getInitials('Alice B Wonderland')).toBe('AW');
+ });
+
+ it('returns U when name is undefined', () => {
+ expect(getInitials(undefined)).toBe('U');
+ });
+
+ it('returns U for an empty string', () => {
+ expect(getInitials('')).toBe('U');
+ });
+
+ it('returns U for a whitespace-only string', () => {
+ expect(getInitials(' ')).toBe('U');
+ });
+});
+
+describe('getMemberDisplayName', () => {
+ it('should prefer the member full name when given and family names are present', () => {
+ expect(
+ getMemberDisplayName(
+ createMockMember({
+ given_name: 'Ada',
+ family_name: 'Lovelace',
+ name: 'Ignored Name',
+ email: 'ada@example.com',
+ }),
+ ),
+ ).toBe('Ada Lovelace');
+ });
+
+ it('should fall back to the member name when full name is unavailable', () => {
+ expect(
+ getMemberDisplayName(
+ createMockMember({
+ given_name: undefined,
+ family_name: undefined,
+ name: 'Grace Hopper',
+ }),
+ ),
+ ).toBe('Grace Hopper');
+ });
+
+ it('should fall back to email when no name fields are available', () => {
+ expect(
+ getMemberDisplayName(
+ createMockMember({
+ given_name: undefined,
+ family_name: undefined,
+ name: '',
+ email: 'fallback@example.com',
+ }),
+ ),
+ ).toBe('fallback@example.com');
+ });
+});
+
+describe('getRelativeLastLoginLabel', () => {
+ const mockT = ((_key: string, vars?: Record, fallback?: string): string => {
+ const template = fallback ?? '';
+ if (!vars) return template;
+ return template.replace(/\$\{(\w+)\}/g, (_, name) => String(vars[name] ?? ''));
+ }) as unknown as Parameters[1];
+
+ it('should return Never for missing or invalid timestamps', () => {
+ expect(getRelativeLastLoginLabel(undefined, mockT)).toBe('Never');
+ expect(getRelativeLastLoginLabel('not-a-date', mockT)).toBe('Never');
+ });
+
+ it.each([
+ {
+ description: 'times under one minute',
+ now: '2026-05-18T12:00:30.000Z',
+ lastLogin: '2026-05-18T12:00:00.000Z',
+ expected: 'Just now',
+ },
+ {
+ description: 'a single minute',
+ now: '2026-05-18T12:01:00.000Z',
+ lastLogin: '2026-05-18T12:00:00.000Z',
+ expected: '1 minute ago',
+ },
+ {
+ description: 'multiple minutes',
+ now: '2026-05-18T12:45:00.000Z',
+ lastLogin: '2026-05-18T12:00:00.000Z',
+ expected: '45 minutes ago',
+ },
+ {
+ description: 'a single hour',
+ now: '2026-05-18T13:00:00.000Z',
+ lastLogin: '2026-05-18T12:00:00.000Z',
+ expected: '1 hour ago',
+ },
+ {
+ description: 'multiple days',
+ now: '2026-05-20T12:00:00.000Z',
+ lastLogin: '2026-05-18T12:00:00.000Z',
+ expected: '2 days ago',
+ },
+ {
+ description: 'weeks',
+ now: '2026-06-01T12:00:00.000Z',
+ lastLogin: '2026-05-18T12:00:00.000Z',
+ expected: '2 weeks ago',
+ },
+ {
+ description: 'months',
+ now: '2026-08-18T12:00:00.000Z',
+ lastLogin: '2026-05-18T12:00:00.000Z',
+ expected: '3 months ago',
+ },
+ {
+ description: 'years',
+ now: '2028-05-18T12:00:00.000Z',
+ lastLogin: '2026-05-18T12:00:00.000Z',
+ expected: '2 years ago',
+ },
+ ])('should format $description correctly', ({ now, lastLogin, expected }) => {
+ vi.spyOn(Date, 'now').mockReturnValue(new Date(now).getTime());
+
+ expect(getRelativeLastLoginLabel(lastLogin, mockT)).toBe(expected);
+ });
+});
diff --git a/packages/react/src/lib/utils/my-organization/member-management/member-management-utils.ts b/packages/react/src/lib/utils/my-organization/member-management/member-management-utils.ts
new file mode 100644
index 000000000..760a88cd4
--- /dev/null
+++ b/packages/react/src/lib/utils/my-organization/member-management/member-management-utils.ts
@@ -0,0 +1,123 @@
+/**
+ * Member management utility functions.
+ * @module member-management-utils
+ * @internal
+ */
+
+import type {
+ EnhancedTranslationFunction,
+ MemberInvitation,
+ OrgMember,
+} from '@auth0/universal-components-core';
+
+import type { InvitationStatus } from '@/types/my-organization/member-management/organization-invitation-table-types';
+
+/**
+ * Resolves the best display name for a member.
+ * @param member - The organization member.
+ * @returns The member display name.
+ */
+export function getMemberDisplayName(member: OrgMember): string {
+ const fullName = `${member.given_name ?? ''} ${member.family_name ?? ''}`.trim();
+
+ return fullName || member.name || member.email || member.user_id || '-';
+}
+
+/**
+ * Determines the status of an invitation based on `expires_at`.
+ * @param invitation - The invitation to check
+ * @returns The invitation status
+ */
+export function getInvitationStatus(invitation: MemberInvitation): InvitationStatus {
+ const isExpired = invitation.expires_at && new Date(invitation.expires_at) < new Date();
+
+ return isExpired ? 'expired' : 'pending';
+}
+
+/**
+ * Formats a member's last login time as a label.
+ * @param lastLogin - The member last login timestamp.
+ * @param t - Translator function (namespace: `member_management`).
+ * @returns A human readable relative time label.
+ */
+export function getRelativeLastLoginLabel(
+ lastLogin: string | undefined,
+ t: EnhancedTranslationFunction,
+): string {
+ const never = t('member.table.never', undefined, 'Never');
+ const ago = t('member.table.ago', undefined, 'ago');
+ const justNow = t('member.table.just_now', undefined, 'Just now');
+
+ if (!lastLogin) {
+ return never;
+ }
+
+ const lastLoginDate = new Date(lastLogin);
+
+ if (Number.isNaN(lastLoginDate.getTime())) {
+ return never;
+ }
+
+ const diffInMs = Date.now() - lastLoginDate.getTime();
+
+ if (diffInMs < 60 * 1000) {
+ return justNow;
+ }
+
+ const translateUnit = (
+ count: number,
+ singularKey: string,
+ pluralKey: string,
+ singularFallback: string,
+ pluralFallback: string,
+ ): string => {
+ const unit =
+ count === 1
+ ? t(`member.table.${singularKey}`, undefined, singularFallback)
+ : t(`member.table.${pluralKey}`, undefined, pluralFallback);
+ return `${count} ${unit} ${ago}`;
+ };
+
+ const diffInMinutes = Math.floor(diffInMs / (60 * 1000));
+ if (diffInMinutes < 60) {
+ return translateUnit(diffInMinutes, 'minute', 'minutes', 'minute', 'minutes');
+ }
+
+ const diffInHours = Math.floor(diffInMinutes / 60);
+ if (diffInHours < 24) {
+ return translateUnit(diffInHours, 'hour', 'hours', 'hour', 'hours');
+ }
+
+ const diffInDays = Math.floor(diffInHours / 24);
+ if (diffInDays < 7) {
+ return translateUnit(diffInDays, 'day', 'days', 'day', 'days');
+ }
+
+ const diffInWeeks = Math.floor(diffInDays / 7);
+ if (diffInWeeks < 4) {
+ return translateUnit(diffInWeeks, 'week', 'weeks', 'week', 'weeks');
+ }
+
+ const diffInMonths = Math.floor(diffInDays / 30);
+ if (diffInMonths < 12) {
+ return translateUnit(diffInMonths, 'month', 'months', 'month', 'months');
+ }
+
+ const diffInYears = Math.floor(diffInDays / 365);
+ return translateUnit(diffInYears, 'year', 'years', 'year', 'years');
+}
+
+/**
+ * Returns up to 2 uppercase initials from a display name, or 'U' if the name is empty.
+ * @param name - The display name to extract initials from
+ * @returns Up to 2 uppercase initials, or 'U' if the name is empty
+ */
+export function getInitials(name?: string): string {
+ const trimmed = name?.trim();
+ if (!trimmed) return 'U';
+ const parts = trimmed.split(/\s+/);
+ const first = parts[0] ?? '';
+ if (parts.length === 1) return first.charAt(0).toUpperCase();
+ const last = parts[parts.length - 1] ?? '';
+ return (first.charAt(0) + last.charAt(0)).toUpperCase();
+}
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 2964c44ac..b3a5e1844 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,6 +8,7 @@ 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 { createMockOrganization } from '@/tests/utils/__mocks__/my-organization/organization-management/organization-details.mocks';
const createMockMyAccountApiService = (): CoreClientInterface['myAccountApiClient'] => {
@@ -55,6 +56,21 @@ const createMockMyOrgApiService = (): CoreClientInterface['myOrganizationApiClie
delete: vi.fn().mockResolvedValue(undefined),
},
},
+ invitations: {
+ list: vi.fn().mockResolvedValue({
+ data: [createMockInvitation()],
+ response: { next: null },
+ }),
+ get: vi.fn().mockResolvedValue(createMockInvitation()),
+ 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({}),
@@ -95,6 +111,24 @@ const createMockMyOrgApiService = (): CoreClientInterface['myOrganizationApiClie
}),
},
},
+ members: {
+ list: vi.fn().mockResolvedValue({ data: [], response: { next: null, total: 0 } }),
+ get: vi.fn().mockResolvedValue({
+ user_id: 'auth0|123234235',
+ name: 'Test User',
+ email: 'test@example.com',
+ created_at: '2025-01-01T00:00:00.000Z',
+ last_login: '2025-01-01T00:00:00.000Z',
+ }),
+ roles: {
+ list: vi.fn().mockResolvedValue({ roles: [] }),
+ assign: vi.fn().mockResolvedValue({}),
+ unassign: vi.fn().mockResolvedValue({}),
+ },
+ },
+ memberships: {
+ deleteMemberships: vi.fn().mockResolvedValue(undefined),
+ },
},
} as unknown as NonNullable;
return service;
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
new file mode 100644
index 000000000..1dbc318ec
--- /dev/null
+++ b/packages/react/src/tests/utils/__mocks__/my-organization/member-management/invitation.mocks.ts
@@ -0,0 +1,107 @@
+import type { MemberInvitation, Role } from '@auth0/universal-components-core';
+import { vi } from 'vitest';
+
+import type {
+ IdentityProviderOption,
+ OrganizationInvitationCreateModalProps,
+ OrganizationInvitationDetailsModalProps,
+ OrganizationInvitationRevokeModalProps,
+ OrganizationInvitationTableActionsColumnProps,
+ SearchFilterProps,
+} from '@/types/my-organization/member-management/organization-invitation-table-types';
+
+export const createMockInvitation = (overrides?: Partial): MemberInvitation => ({
+ id: 'inv_abc123xyz456',
+ invitee: { email: 'test@example.com' },
+ inviter: { name: 'Admin User' },
+ roles: ['role_admin'],
+ created_at: '2024-01-01T00:00:00.000Z',
+ expires_at: '2099-12-31T23:59:59.000Z',
+ invitation_url: 'https://example.auth0.com/invitation?ticket=abc123',
+ ...overrides,
+});
+
+export const createMockPendingInvitation = (
+ overrides?: Partial,
+): MemberInvitation =>
+ createMockInvitation({
+ invitation_url: 'https://example.auth0.com/invitation?ticket=pending123',
+ ...overrides,
+ });
+
+export const createMockExpiredInvitation = (
+ overrides?: Partial,
+): MemberInvitation =>
+ createMockInvitation({
+ expires_at: '2020-01-01T00:00:00.000Z',
+ invitation_url: undefined,
+ ...overrides,
+ });
+
+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 createMockCreateModalProps = (
+ overrides: Partial = {},
+): OrganizationInvitationCreateModalProps => ({
+ isOpen: true,
+ isLoading: false,
+ onClose: vi.fn(),
+ onCreate: vi.fn(),
+ ...overrides,
+});
+
+export const createMockActionsColumnProps = (
+ overrides: Partial = {},
+): OrganizationInvitationTableActionsColumnProps => ({
+ invitation: createMockPendingInvitation(),
+ readOnly: false,
+ onViewDetails: vi.fn(),
+ onCopyUrl: vi.fn(),
+ onRevokeAndResend: vi.fn(),
+ onRevoke: vi.fn(),
+ ...overrides,
+});
+
+export const createMockDetailsModalProps = (
+ overrides: Partial = {},
+): OrganizationInvitationDetailsModalProps => ({
+ invitation: createMockPendingInvitation(),
+ isOpen: true,
+ isRevoking: false,
+ isResending: false,
+ onClose: vi.fn(),
+ onCopyUrl: vi.fn(),
+ onRevoke: vi.fn(),
+ onResend: vi.fn(),
+ ...overrides,
+});
+
+export const createMockRevokeModalProps = (
+ overrides: Partial = {},
+): OrganizationInvitationRevokeModalProps => ({
+ invitation: createMockPendingInvitation(),
+ isOpen: true,
+ isLoading: false,
+ isRevokeAndResend: false,
+ onClose: vi.fn(),
+ onConfirm: vi.fn(),
+ ...overrides,
+});
+
+export const createMockSearchFilterProps = (
+ overrides: Partial = {},
+): SearchFilterProps => ({
+ filters: {},
+ availableRoles: createMockRoles(),
+ onRoleFilterChange: vi.fn(),
+ ...overrides,
+});
diff --git a/packages/react/src/tests/utils/__mocks__/my-organization/member-management/member.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/member-management/member.mocks.ts
new file mode 100644
index 000000000..5d64a46a1
--- /dev/null
+++ b/packages/react/src/tests/utils/__mocks__/my-organization/member-management/member.mocks.ts
@@ -0,0 +1,230 @@
+import type { OrgMember, Role } from '@auth0/universal-components-core';
+import { vi } from 'vitest';
+
+import type {
+ MemberDetailDangerZoneProps,
+ MemberDetailModalState,
+ OrganizationMemberAssignRolesModalProps,
+ OrganizationMemberDetailProps,
+ OrganizationMemberDetailRolesTabProps,
+ OrganizationMemberDetailViewProps,
+ OrganizationMemberUserDetailsProps,
+ MemberRemoveFromOrgModalProps,
+ OrganizationMemberRemoveRoleModalProps,
+} from '@/types/my-organization/member-management/organization-member-detail-types';
+import type {
+ OrganizationMemberTableActionsColumnProps,
+ OrganizationMemberTableProps,
+} from '@/types/my-organization/member-management/organization-member-table-types';
+
+export const createMockMember = (overrides?: Partial): OrgMember =>
+ ({
+ user_id: 'auth0|testuser123',
+ name: 'Test User',
+ email: 'test@example.com',
+ created_at: '2024-01-01T00:00:00.000Z',
+ last_login: '2024-06-15T10:00:00.000Z',
+ given_name: 'Ada',
+ family_name: 'Lovelace',
+ roles: [{ id: 'role_admin', name: 'Admin' }],
+ picture: undefined,
+ ...overrides,
+ }) as OrgMember;
+
+export const createMockMembers = (): OrgMember[] => [
+ createMockMember(),
+ createMockMember({
+ user_id: 'auth0|testuser123',
+ name: 'Test User',
+ email: 'test@example.com',
+ created_at: '2024-01-01T00:00:00.000Z',
+ given_name: 'Ada',
+ family_name: 'Lovelace',
+ last_login: undefined,
+ roles: [],
+ }),
+];
+
+export const createMockMemberActionsColumnProps = (
+ overrides: Partial = {},
+): OrganizationMemberTableActionsColumnProps => ({
+ member: createMockMember(),
+ onViewDetails: vi.fn(),
+ onAssignRole: vi.fn(),
+ onRemoveFromOrg: vi.fn(),
+ ...overrides,
+});
+
+export const createMockMemberTableProps = (
+ overrides: Partial = {},
+): OrganizationMemberTableProps => ({
+ members: createMockMembers(),
+ loading: false,
+ pagination: {
+ pageSize: 10,
+ currentPage: 1,
+ totalItems: 2,
+ hasNextPage: true,
+ hasPreviousPage: false,
+ },
+ filters: {},
+ sortConfig: { key: null, direction: 'asc' },
+ availableRoles: [],
+ onView: vi.fn(),
+ onAssignRole: vi.fn(),
+ onRemoveFromOrg: vi.fn(),
+ onNextPage: vi.fn(),
+ onPreviousPage: vi.fn(),
+ onPageSizeChange: vi.fn(),
+ onSortChange: vi.fn(),
+ onRoleFilterChange: vi.fn(),
+ onSearchTermChange: vi.fn(),
+ ...overrides,
+});
+
+export const createMockRoleOptions = (): Role[] => [
+ { id: 'role_admin', name: 'Admin' },
+ { id: 'role_member', name: 'Member' },
+ { id: 'role_viewer', name: 'Viewer' },
+];
+
+export const createMockMemberWithPhone = (overrides?: Partial): OrgMember =>
+ createMockMember({
+ phone_number: '+1234567890',
+ identities: [{ provider: 'Username-Password-Authentication' }],
+ ...overrides,
+ });
+
+export const createMockMemberRole = (overrides?: Partial): Role => ({
+ id: 'rol_abc123',
+ name: 'Admin',
+ description: 'Administrator role',
+ ...overrides,
+});
+
+export const createMockMemberRoles = (): Role[] => [
+ createMockMemberRole({ id: 'rol_admin', name: 'Admin', description: 'Administrator role' }),
+ createMockMemberRole({ id: 'rol_member', name: 'Member', description: 'Member role' }),
+];
+
+export const createMockAvailableRoles = (): Role[] => [
+ { id: 'rol_admin', name: 'Admin', description: 'Administrator role' },
+ { id: 'rol_member', name: 'Member', description: 'Member role' },
+ { id: 'rol_viewer', name: 'Viewer', description: 'Viewer role' },
+];
+
+export const createMockUserDetailsProps = (
+ overrides: Partial = {},
+): OrganizationMemberUserDetailsProps => ({
+ member: createMockMember(),
+ ...overrides,
+});
+
+export const createMockDangerZoneProps = (
+ overrides: Partial = {},
+): MemberDetailDangerZoneProps => ({
+ readOnly: false,
+ isRemovingFromOrg: false,
+ onRemoveFromOrgClick: vi.fn(),
+ ...overrides,
+});
+
+export const createMockRemoveFromOrgModalProps = (
+ overrides: Partial = {},
+): MemberRemoveFromOrgModalProps => ({
+ isOpen: true,
+ isLoading: false,
+ onClose: vi.fn(),
+ onConfirm: vi.fn(),
+ ...overrides,
+});
+
+export const createMockRolesTabProps = (
+ overrides: Partial = {},
+): OrganizationMemberDetailRolesTabProps => ({
+ memberRoles: createMockMemberRoles(),
+ availableRoles: createMockAvailableRoles(),
+ isLoading: false,
+ removingRoleIds: [],
+ readOnly: false,
+ onAssignRolesClick: vi.fn(),
+ onRemoveRoles: vi.fn(),
+ ...overrides,
+});
+
+export const createMockAssignRolesModalProps = (
+ overrides: Partial = {},
+): OrganizationMemberAssignRolesModalProps => ({
+ isOpen: true,
+ isLoading: false,
+ availableRoles: createMockAvailableRoles(),
+ assignedRoles: [],
+ onClose: vi.fn(),
+ onAssign: vi.fn(),
+ ...overrides,
+});
+
+export const createMockRemoveRoleModalProps = (
+ overrides: Partial = {},
+): OrganizationMemberRemoveRoleModalProps => ({
+ isOpen: true,
+ isLoading: false,
+ roles: [createMockMemberRole()],
+ onClose: vi.fn(),
+ onConfirm: vi.fn(),
+ ...overrides,
+});
+
+export const noModal: MemberDetailModalState = { type: null };
+
+export const createMockOrganizationMemberDetailProps = (
+ overrides?: Partial,
+): OrganizationMemberDetailProps => ({
+ userId: 'auth0|testuser123',
+ onBack: vi.fn(),
+ customMessages: {},
+ styling: {
+ variables: { common: {}, light: {}, dark: {} },
+ classes: {},
+ },
+ removeFromOrgAction: undefined,
+ assignRolesAction: undefined,
+ removeRolesAction: undefined,
+ ...overrides,
+});
+
+export const createMockOrganizationMemberDetailViewProps = (
+ overrides?: Partial,
+): OrganizationMemberDetailViewProps => ({
+ styling: {
+ variables: { common: {}, light: {}, dark: {} },
+ classes: {},
+ },
+ customMessages: {},
+ activeTab: 'details',
+ member: createMockMember(),
+ orgDisplayName: 'Test Org',
+ memberRoles: createMockMemberRoles(),
+ availableRoles: createMockAvailableRoles(),
+ selectedRoles: [],
+ isFetchingMember: false,
+ isFetchingMemberRoles: false,
+ isFetchingAvailableRoles: false,
+ isLoading: false,
+ memberError: null,
+ isRemovingFromOrg: false,
+ isAssigningRoles: false,
+ isRemovingRoles: false,
+ removingRoleIds: [],
+ modalState: noModal,
+ setActiveTab: vi.fn(),
+ setSelectedRoles: vi.fn(),
+ handleBack: vi.fn(),
+ openModal: vi.fn(),
+ closeModal: vi.fn(),
+ handleRemoveFromOrgConfirm: vi.fn(),
+ handleAssignRolesSubmit: vi.fn(),
+ handleRemoveRolesCancel: vi.fn(),
+ handleRemoveRolesConfirm: vi.fn(),
+ ...overrides,
+});
diff --git a/packages/react/src/types/index.ts b/packages/react/src/types/index.ts
index b46dc7304..0209418b7 100644
--- a/packages/react/src/types/index.ts
+++ b/packages/react/src/types/index.ts
@@ -28,3 +28,7 @@ export * from './my-organization/idp-management/sso-provisioning/provisioning-to
export * from './my-organization/idp-management/sso-provisioning/sso-provisioning-tab-types';
export * from './my-organization/organization-management/organization-details-edit-types';
export * from './my-organization/organization-management/organization-details-types';
+export * from './my-organization/member-management/organization-invitation-table-types';
+export * from './my-organization/member-management/organization-member-management-types';
+export * from './my-organization/member-management/organization-member-detail-types';
+export * from './my-organization/member-management/organization-member-table-types';
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
new file mode 100644
index 000000000..b1660e3f4
--- /dev/null
+++ b/packages/react/src/types/my-organization/member-management/organization-invitation-table-types.ts
@@ -0,0 +1,154 @@
+/**
+ * Organization invitation table types.
+ * @module organization-invitation-table-types
+ */
+
+import type {
+ SharedComponentProps,
+ ComponentAction,
+ MemberInvitation,
+ InvitationCreateSchemas,
+ OrganizationInvitationTabMessages,
+ Role,
+} from '@auth0/universal-components-core';
+
+import type {
+ MemberManagementFilterState,
+ MemberManagementPaginationState,
+ MemberManagementSortConfig,
+} from '@/types';
+
+/** Invitation status. */
+export type InvitationStatus = 'pending' | 'expired';
+
+/** Identity provider option for invitation. */
+export interface IdentityProviderOption {
+ id: string;
+ name: string;
+ type?: string;
+}
+
+/** Input for creating invitation(s). Supports bulk invite via invitees array. */
+export interface CreateInvitationInput {
+ invitees: Array<{
+ email: string;
+ roles?: string[];
+ }>;
+ inviter?: {
+ name?: string;
+ };
+ identity_provider_id?: string;
+ /** Time to live in seconds */
+ ttl_sec?: number;
+}
+
+/** CSS classes for OrganizationInvitationTab. */
+export interface OrganizationInvitationTabClasses {
+ 'OrganizationInvitationTab-root'?: string;
+ 'OrganizationInvitationTab-table'?: string;
+ 'OrganizationInvitationTab-createModal'?: string;
+ 'OrganizationInvitationTab-detailsModal'?: string;
+ 'OrganizationInvitationTab-revokeModal'?: string;
+ 'OrganizationInvitationTab-revokeResendModal'?: string;
+ 'OrganizationInvitationTab-searchInput'?: string;
+ 'OrganizationInvitationTab-filterDropdown'?: string;
+ 'OrganizationInvitationTab-pagination'?: string;
+}
+
+/** Props for OrganizationInvitationTab component. */
+export interface OrganizationInvitationTabProps
+ extends SharedComponentProps<
+ OrganizationInvitationTabMessages,
+ OrganizationInvitationTabClasses
+ > {
+ createAction?: ComponentAction;
+ revokeAction?: ComponentAction;
+}
+
+/** Props for OrganizationInvitationTableActionsColumn component. */
+export interface OrganizationInvitationTableActionsColumnProps {
+ invitation: MemberInvitation;
+ customMessages?: Partial;
+ readOnly?: boolean;
+ onViewDetails?: (invitation: MemberInvitation) => void;
+ onCopyUrl?: (invitation: MemberInvitation) => void;
+ onRevokeAndResend?: (invitation: MemberInvitation) => void;
+ onRevoke?: (invitation: MemberInvitation) => void;
+}
+
+/** Props for OrganizationInvitationTable component. */
+export interface OrganizationInvitationTableProps {
+ invitations: MemberInvitation[];
+ loading?: boolean;
+ customMessages?: Partial;
+ pagination: MemberManagementPaginationState;
+ pageSizeOptions?: number[];
+ filters?: MemberManagementFilterState;
+ sortConfig?: MemberManagementSortConfig;
+ availableRoles?: Role[];
+ readOnly?: boolean;
+ onView?: (invitation: MemberInvitation) => void;
+ onCopyUrl?: (invitation: MemberInvitation) => void;
+ onRevokeAndResend?: (invitation: MemberInvitation) => void;
+ onRevoke?: (invitation: MemberInvitation) => void;
+ onNextPage?: () => void;
+ onPreviousPage?: () => void;
+ onPageSizeChange?: (pageSize: number) => void;
+ onSortChange?: (sortConfig: MemberManagementSortConfig) => void;
+ onRoleFilterChange?: (roleId: string | undefined) => void;
+ className?: string;
+}
+
+/** Props for SearchFilter component. */
+export interface SearchFilterProps {
+ filters?: MemberManagementFilterState;
+ availableRoles?: Role[];
+ customMessages?: Partial;
+ className?: string;
+ activeTab?: string | undefined;
+ onRoleFilterChange?: (roleId: string | undefined) => void;
+ onSearchTermChange?: (searchTerm: string) => void;
+}
+
+/** Props for OrganizationInvitationCreateModal component. */
+export interface OrganizationInvitationCreateModalProps {
+ isOpen: boolean;
+ isLoading?: boolean;
+ customMessages?: Partial;
+ availableRoles?: Role[];
+ availableProviders?: IdentityProviderOption[];
+ inviterName?: string;
+ schema?: InvitationCreateSchemas;
+ onClose: () => void;
+ onCreate: (data: CreateInvitationInput) => void;
+ className?: string;
+}
+
+/** Props for OrganizationInvitationDetailsModal component. */
+export interface OrganizationInvitationDetailsModalProps {
+ invitation: MemberInvitation | null;
+ isOpen: boolean;
+ isRevoking?: boolean;
+ isResending?: boolean;
+ customMessages?: Partial;
+ availableRoles?: Role[];
+ availableProviders?: IdentityProviderOption[];
+ readOnly?: boolean;
+ onClose: () => void;
+ onCopyUrl?: (invitation: MemberInvitation) => void;
+ onRevoke?: (invitation?: MemberInvitation) => void;
+ onResend?: (invitation?: MemberInvitation) => void;
+ className?: string;
+}
+
+/** Props for OrganizationInvitationRevokeModal component. */
+export interface OrganizationInvitationRevokeModalProps {
+ invitation: MemberInvitation | null;
+ isOpen: boolean;
+ isLoading?: boolean;
+ isRevokeAndResend?: boolean;
+ customMessages?: Partial;
+ onClose: () => void;
+ onConfirm: (invitation: MemberInvitation) => void;
+ className?: string;
+}
diff --git a/packages/react/src/types/my-organization/member-management/organization-member-detail-types.ts b/packages/react/src/types/my-organization/member-management/organization-member-detail-types.ts
new file mode 100644
index 000000000..fa9009bf3
--- /dev/null
+++ b/packages/react/src/types/my-organization/member-management/organization-member-detail-types.ts
@@ -0,0 +1,230 @@
+/**
+ * Organization member detail types.
+ * @module organization-member-detail-types
+ */
+
+import type {
+ ComponentAction,
+ SharedComponentProps,
+ OrgMember,
+ OrganizationPrivate,
+ Role,
+ OrganizationMemberDetailMessages,
+ OrganizationMemberTabMessages,
+} from '@auth0/universal-components-core';
+import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query';
+
+export type MemberDetailTab = 'details' | 'roles';
+
+export interface MemberDetailServiceResult {
+ memberQuery: UseQueryResult;
+ memberRolesQuery: UseQueryResult;
+ rolesQuery: UseQueryResult;
+ organizationQuery: UseQueryResult;
+ removeFromOrgMutation: UseMutationResult<
+ void,
+ Error,
+ { userId?: string; memberName?: string; orgName?: string }
+ >;
+ assignRolesMutation: UseMutationResult;
+ removeRolesMutation: UseMutationResult;
+}
+
+export interface UseOrganizationMemberDetailOptions {
+ userId: string;
+ onBack?: () => void;
+ customMessages?: Partial;
+ readOnly?: boolean;
+ removeFromOrgAction?: ComponentAction;
+ assignRolesAction?: ComponentAction<{ userId: string; roleIds: string[] }>;
+ removeRolesAction?: ComponentAction<{ userId: string; roleIds: string[] }>;
+}
+
+/** Discriminated union for member detail modal state. */
+export type MemberDetailModalState =
+ | { type: null }
+ | { type: 'removeFromOrg' }
+ | { type: 'assignRoles' }
+ | { type: 'removeRoles'; roles: Role[] };
+
+export interface UseOrganizationMemberDetailResult {
+ activeTab: MemberDetailTab;
+ member: OrgMember | null;
+ orgDisplayName: string;
+ memberRoles: Role[];
+ availableRoles: Role[];
+ selectedRoles: Role[];
+ isLoading: boolean;
+ memberError: string | null;
+ isFetchingMember: boolean;
+ isFetchingMemberRoles: boolean;
+ isFetchingAvailableRoles: boolean;
+ isRemovingFromOrg: boolean;
+ isAssigningRoles: boolean;
+ isRemovingRoles: boolean;
+ removingRoleIds: string[];
+ modalState: MemberDetailModalState;
+
+ setActiveTab: (tab: MemberDetailTab) => void;
+ setSelectedRoles: (roles: Role[]) => void;
+ handleBack: () => void;
+ openModal: (state: MemberDetailModalState) => void;
+ closeModal: () => void;
+ handleRemoveFromOrgConfirm: (memberName?: string, orgName?: string) => void;
+ handleAssignRolesSubmit: (roleIds: string[]) => void;
+ handleRemoveRolesCancel: () => void;
+ handleRemoveRolesConfirm: () => void;
+}
+
+/** CSS classes for OrganizationMemberDetail. */
+export interface OrganizationMemberDetailClasses {
+ 'OrganizationMemberDetail-root'?: string;
+ 'OrganizationMemberDetail-header'?: string;
+ 'OrganizationMemberDetail-tabs'?: string;
+ 'OrganizationMemberDetail-detailsTab'?: string;
+ 'OrganizationMemberDetail-rolesTab'?: string;
+}
+
+export interface OrganizationMemberUserDetailsProps {
+ member: OrgMember;
+ customMessages?: Partial;
+}
+
+export interface RemoveMemberFromOrganizationCardProps {
+ customMessages?: Partial;
+ isRemovingFromOrg: boolean;
+ onRemoveFromOrgClick: () => void;
+}
+
+export interface OrganizationMemberEditDetailsTabProps {
+ member: OrgMember | null;
+ customMessages?: Partial;
+ isRemovingFromOrg: boolean;
+ onRemoveFromOrgClick: () => void;
+}
+
+export interface MemberDetailDangerCardProps {
+ title: string;
+ description: string;
+ buttonLabel: string;
+ isLoading?: boolean;
+ disabled?: boolean;
+ onClick: () => void;
+}
+
+export interface MemberDetailDangerZoneProps {
+ readOnly?: boolean;
+ isRemovingFromOrg?: boolean;
+ customMessages?: Partial;
+ onRemoveFromOrgClick: () => void;
+}
+
+export interface MemberRemoveFromOrgModalProps {
+ isOpen: boolean;
+ isLoading?: boolean;
+ memberName?: string;
+ memberUserId?: string;
+ orgName?: string;
+ customMessages?: Partial;
+ onClose: () => void;
+ onConfirm: (userId?: string, memberName?: string, orgName?: string) => void;
+}
+
+export interface OrganizationMemberDetailRolesTabProps {
+ memberRoles: Role[];
+ availableRoles: Role[];
+ isLoading?: boolean;
+ removingRoleIds?: string[];
+ readOnly?: boolean;
+ customMessages?: Partial;
+ onAssignRolesClick: () => void;
+ onRemoveRoles: (roles: Role[]) => void;
+}
+
+export interface OrganizationMemberRemoveRoleModalProps {
+ isOpen: boolean;
+ isLoading?: boolean;
+ roles: Role[];
+ memberName?: string;
+ customMessages?: Partial;
+ onClose: () => void;
+ onConfirm: () => void;
+}
+
+export interface OrganizationMemberAssignRolesModalProps {
+ isOpen: boolean;
+ isLoading?: boolean;
+ availableRoles: Role[];
+ assignedRoles: Role[];
+ customMessages?: Partial;
+ selectedMember?: OrgMember | null;
+ onClose: () => void;
+ onAssign: (roleIds: string[], userId?: string | null) => void;
+}
+
+export interface RolesTabHeaderProps {
+ selectedRoles: Role[];
+ orgName?: string;
+ customMessages?: Partial;
+ onAssignRolesClick: () => void;
+ onRemoveSelectedRoles: () => void;
+}
+
+export interface OrganizationMemberEditRolesTableProps {
+ memberRoles: Role[];
+ isLoading?: boolean;
+ removingRoleIds?: string[];
+ selectedRoles: Role[];
+ customMessages?: Partial;
+ onRemoveRoles: (roles: Role[]) => void;
+ onSelectedRolesChange: (roles: Role[]) => void;
+}
+
+export interface OrganizationMemberEditRolesTabProps {
+ customMessages?: Partial;
+ orgName?: string;
+ memberName?: string;
+ memberRoles: Role[];
+ availableRoles: Role[];
+ selectedRoles: Role[];
+ isFetchingMemberRoles?: boolean;
+ isFetchingAvailableRoles?: boolean;
+ removingRoleIds?: string[];
+ isAssigningRoles?: boolean;
+ isRemovingRoles?: boolean;
+ modalState: MemberDetailModalState;
+ onSelectedRolesChange: (roles: Role[]) => void;
+ onAssignRolesClick: () => void;
+ onAssignRolesCancel: () => void;
+ onAssignRolesSubmit: (roleIds: string[]) => void;
+ onRemoveRolesClick: (roles: Role[]) => void;
+ onRemoveRolesCancel: () => void;
+ onRemoveRolesConfirm: () => void;
+}
+
+/** Props for OrganizationMemberDetail component. */
+export interface OrganizationMemberDetailProps
+ extends SharedComponentProps {
+ userId: string;
+ onBack?: () => void;
+ hideHeader?: boolean;
+ removeFromOrgAction?: ComponentAction;
+ assignRolesAction?: ComponentAction<{ userId: string; roleIds: string[] }>;
+ removeRolesAction?: ComponentAction<{ userId: string; roleIds: string[] }>;
+}
+
+/** Props for OrganizationMemberDetailView component. */
+export interface OrganizationMemberDetailViewProps extends UseOrganizationMemberDetailResult {
+ styling: OrganizationMemberDetailProps['styling'];
+ customMessages: OrganizationMemberDetailProps['customMessages'];
+}
+
+export type MemberDetailHeaderProps = Pick<
+ OrganizationMemberDetailViewProps,
+ 'member' | 'styling' | 'customMessages' | 'handleBack'
+>;
+
+export type UseMemberDetailServiceOptions = Pick<
+ UseOrganizationMemberDetailOptions,
+ 'userId' | 'customMessages' | 'removeFromOrgAction' | 'assignRolesAction' | 'removeRolesAction'
+>;
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
new file mode 100644
index 000000000..80dc33509
--- /dev/null
+++ b/packages/react/src/types/my-organization/member-management/organization-member-management-types.ts
@@ -0,0 +1,217 @@
+/**
+ * Organization member management types.
+ * @module organization-member-management-types
+ */
+
+import type {
+ ComponentAction,
+ SharedComponentProps,
+ MemberInvitation,
+ OrganizationMemberManagementMessages,
+ Role,
+ OrgMember,
+ OrganizationPrivate,
+} from '@auth0/universal-components-core';
+import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query';
+
+import type {
+ CreateInvitationInput,
+ IdentityProviderOption,
+ OrganizationInvitationTabClasses,
+} from './organization-invitation-table-types';
+
+export type ActiveTab = 'members' | 'invitations';
+
+/** Pagination state for member management tables - invitation and member tables (checkpoint-based). */
+export interface MemberManagementPaginationState {
+ pageSize: number;
+ currentPage: number;
+ totalItems?: number;
+ hasNextPage: boolean;
+ hasPreviousPage: boolean;
+}
+
+/** Sort configuration for member management tables. */
+export interface MemberManagementSortConfig {
+ key: string | null;
+ direction: 'asc' | 'desc';
+}
+
+/** Filter state for member management tables. */
+export interface MemberManagementFilterState {
+ searchQuery?: string;
+ roleId?: string;
+}
+
+export interface TableQueryParams {
+ pageSize: number;
+ fromToken: string | undefined;
+ sortConfig: TSort;
+ filters: TFilter;
+}
+
+export interface AssignRoleMutationInput {
+ userId: string;
+ roleIds: string[];
+}
+
+export interface UseMemberManagementServiceOptions {
+ customMessages?: Partial;
+ activeTab?: ActiveTab;
+ createInvitationAction?: ComponentAction;
+ revokeInvitationAction?: ComponentAction;
+ resendInvitationAction?: ComponentAction;
+ invitationParams?: TableQueryParams;
+ memberParams?: TableQueryParams;
+ viewMemberDetailsAction?: ComponentAction;
+ assignRolesAction?: ComponentAction<{ userId: string; roleIds: string[] }>;
+ removeFromOrgAction?: ComponentAction;
+}
+
+export interface MemberManagementServiceResult {
+ providersQuery: UseQueryResult;
+ rolesQuery: UseQueryResult;
+ invitationsQuery: UseQueryResult<{
+ invitations: MemberInvitation[];
+ next: string | null;
+ }>;
+ membersQuery: UseQueryResult<{
+ members: OrgMember[];
+ next: string | undefined | null;
+ }>;
+ organizationQuery: UseQueryResult;
+ assignRolesMutation: UseMutationResult<
+ void,
+ Error,
+ { roleIds: string[]; userId?: string | null }
+ >;
+ removeFromOrgMutation: UseMutationResult<
+ void,
+ Error,
+ { userId?: string | null; memberName?: string; orgName?: string }
+ >;
+ createInvitationMutation: UseMutationResult<
+ MemberInvitation | undefined,
+ Error,
+ CreateInvitationInput
+ >;
+ revokeInvitationMutation: UseMutationResult;
+ resendInvitationMutation: UseMutationResult<
+ MemberInvitation | undefined,
+ Error,
+ MemberInvitation
+ >;
+ fetchInvitationDetails: (invitationId: string) => Promise;
+}
+
+export interface UseOrganizationMemberManagementOptions {
+ customMessages?: Partial;
+ readOnly?: boolean;
+ /** Action hooks for invitation creation (onBefore/onAfter) */
+ createInvitationAction?: ComponentAction;
+ /** Action hooks for invitation revocation (onBefore/onAfter) */
+ revokeInvitationAction?: ComponentAction;
+ /** Action hooks for invitation revoke-and-resend (onBefore/onAfter) */
+ resendInvitationAction?: ComponentAction;
+ /** Action hooks for viewing member details (onBefore/onAfter) */
+ viewMemberDetailsAction?: ComponentAction;
+ /** Action hooks for assigning a role to a member (onBefore/onAfter) */
+ assignRolesAction?: ComponentAction<{ userId: string; roleIds: string[] }>;
+ /** Action hooks for removing a member from the organization (onBefore/onAfter) */
+ removeFromOrgAction?: ComponentAction;
+}
+
+/** Discriminated union for member management modal state. */
+export type MemberManagementModalState =
+ | { type: null }
+ | { type: 'create' }
+ | { type: 'details'; invitation: MemberInvitation }
+ | { type: 'revoke'; invitation: MemberInvitation }
+ | { type: 'revokeResend'; invitation: MemberInvitation }
+ | { type: 'assignRole'; member: OrgMember }
+ | { type: 'removeFromOrg'; member: OrgMember };
+
+export interface UseOrganizationMemberManagementResult {
+ activeTab: ActiveTab;
+ availableRoles: Role[];
+ availableProviders: IdentityProviderOption[];
+ members: OrgMember[];
+
+ invitations: MemberInvitation[];
+ orgDisplayName?: string;
+ isInitialLoading: boolean;
+ isFetchingInvitations: boolean;
+ isFetchingMembers: boolean;
+ isFetchingAvailableRoles: boolean;
+ isCreatingInvitation: boolean;
+ isRevokingInvitation: boolean;
+ isResendingInvitation: boolean;
+ invitationPagination: MemberManagementPaginationState;
+ memberPagination: MemberManagementPaginationState;
+ invitationFilters?: MemberManagementFilterState;
+ invitationSortConfig?: MemberManagementSortConfig;
+ memberFilters?: MemberManagementFilterState;
+ memberSortConfig?: MemberManagementSortConfig;
+ modalState: MemberManagementModalState;
+ isRemovingFromOrg?: boolean;
+ isAssigningRoles?: boolean;
+
+ setActiveTab: (tab: ActiveTab) => void;
+ openModal: (state: MemberManagementModalState) => void;
+ closeModal: () => void;
+ handleCreateSubmit: (data: CreateInvitationInput) => void;
+ handleRevokeConfirm: () => void;
+ handleRevokeResendConfirm: () => void;
+ handleCopyUrl: (invitation: MemberInvitation) => Promise;
+ handleNextPage: () => void;
+ handlePreviousPage: () => void;
+ handlePageSizeChange: (pageSize: number) => void;
+ handleSortChange: (sortConfig: MemberManagementSortConfig) => void;
+ handleRoleFilterChange: (roleId: string | undefined) => void;
+ handleViewMemberDetails: (userId: string) => void;
+ handleAssignRolesSubmit: (roleIds: string[], userId?: string | null) => void;
+ handleRemoveFromOrgConfirm: (
+ userId?: string | null,
+ memberName?: string,
+ orgName?: string,
+ ) => void;
+}
+
+/**
+ * Props for the OrganizationMemberManagementView component.
+ */
+export interface OrganizationMemberManagementViewProps
+ extends UseOrganizationMemberManagementResult {
+ styling: OrganizationMemberManagementProps['styling'];
+ customMessages: OrganizationMemberManagementProps['customMessages'];
+ hideHeader: boolean;
+ readOnly: boolean;
+}
+
+/** CSS classes for OrganizationMemberManagement. */
+export interface OrganizationMemberManagementClasses extends OrganizationInvitationTabClasses {
+ 'OrganizationMemberManagement-root'?: string;
+ 'OrganizationMemberManagement-header'?: string;
+ 'OrganizationMemberManagement-tabs'?: string;
+}
+
+/** Props for OrganizationMemberManagement component. */
+export interface OrganizationMemberManagementProps
+ extends SharedComponentProps<
+ OrganizationMemberManagementMessages,
+ OrganizationMemberManagementClasses
+ > {
+ hideHeader?: boolean;
+ /** Action hooks for invitation creation (onBefore/onAfter) */
+ createInvitationAction?: ComponentAction;
+ /** Action hooks for invitation revocation (onBefore/onAfter) */
+ revokeInvitationAction?: ComponentAction;
+ /** Action hooks for invitation revoke-and-resend (onBefore/onAfter) */
+ resendInvitationAction?: ComponentAction;
+ /** Action hooks for viewing member details (onBefore/onAfter) */
+ viewMemberDetailsAction?: ComponentAction;
+ /** Action hooks for assigning a role to a member (onBefore/onAfter) */
+ assignRolesAction?: ComponentAction<{ userId: string; roleIds: string[] }>;
+ /** Action hooks for removing a member from the organization (onBefore/onAfter) */
+ removeFromOrgAction?: ComponentAction;
+}
diff --git a/packages/react/src/types/my-organization/member-management/organization-member-table-types.ts b/packages/react/src/types/my-organization/member-management/organization-member-table-types.ts
new file mode 100644
index 000000000..81499827f
--- /dev/null
+++ b/packages/react/src/types/my-organization/member-management/organization-member-table-types.ts
@@ -0,0 +1,48 @@
+/**
+ * Organization member table types.
+ * @module organization-member-table-types
+ */
+
+import type {
+ OrgMember,
+ OrganizationMemberTabMessages,
+ Role,
+} from '@auth0/universal-components-core';
+
+import type {
+ MemberManagementFilterState,
+ MemberManagementPaginationState,
+ MemberManagementSortConfig,
+} from '@/types';
+
+/** Props for OrganizationMemberTableActionsColumn component. */
+export interface OrganizationMemberTableActionsColumnProps {
+ member: OrgMember;
+ customMessages?: Partial;
+ onViewDetails?: (userId: string) => void;
+ onAssignRole?: (member: OrgMember) => void;
+ onRemoveFromOrg?: (member: OrgMember) => void;
+}
+
+/** Props for OrganizationMemberTable component. */
+export interface OrganizationMemberTableProps {
+ members: OrgMember[];
+ loading?: boolean;
+ pagination: MemberManagementPaginationState;
+ pageSizeOptions?: number[];
+ filters?: MemberManagementFilterState;
+ sortConfig?: MemberManagementSortConfig;
+ customMessages?: Partial;
+ availableRoles?: Role[];
+ readOnly?: boolean;
+ className?: string;
+ onView?: (userId: string) => void;
+ onAssignRole?: (member: OrgMember) => void;
+ onRemoveFromOrg?: (member: OrgMember) => void;
+ onNextPage?: () => void;
+ onPreviousPage?: () => void;
+ onPageSizeChange?: (pageSize: number) => void;
+ onSortChange?: (sortConfig: MemberManagementSortConfig) => void;
+ onRoleFilterChange?: (roleId: string | undefined) => void;
+ onSearchTermChange?: (searchTerm: string) => void;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b62e46c8a..8e5954305 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.0.0
- version: 1.0.0
+ specifier: file:../../auth0-myorganization-js-1.0.1.tgz
+ version: file:auth0-myorganization-js-1.0.1.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.0.0':
- resolution: {integrity: sha512-mYGa95tFj3xgUKKVSi4B95Yt4FPppFfbtmWM9fvXUEgwSgmLHre6vHLwcnsXTPB/rF7ATpAtMMIsWq1N5h9Y4w==}
+ '@auth0/myorganization-js@file:auth0-myorganization-js-1.0.1.tgz':
+ resolution: {integrity: sha512-ERWuwJ+YFmdGR736o7p22fl3p3madZlvPIqiD9RXrh3zEWNXb4rR+g0Ssv5D7sbRi7KedTM1Ah3okZ2U4pc1Hw==, tarball: file:auth0-myorganization-js-1.0.1.tgz}
+ version: 1.0.1
engines: {node: '>=20.0.0'}
'@auth0/nextjs-auth0@4.20.0':
@@ -7534,7 +7535,7 @@ snapshots:
'@auth0/myaccount-js@1.0.0-beta.0': {}
- '@auth0/myorganization-js@1.0.0':
+ '@auth0/myorganization-js@file:auth0-myorganization-js-1.0.1.tgz':
dependencies:
'@auth0/auth0-auth-js': 1.5.0