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 00000000..6d2cce4c --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/organization-member-detail.tsx @@ -0,0 +1,238 @@ +/** + * 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 { MemberDeleteModal } from '@/components/auth0/my-organization/shared/member-management/members/member-danger-zone/member-delete-modal'; +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 { 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 type { + OrganizationMemberDetailProps, + OrganizationMemberDetailViewProps, +} from '@/types/my-organization/member-management/organization-member-detail-types'; + +export type { OrganizationMemberDetailViewProps }; + +/** + * Returns the initials (up to 2 chars) from a display name. + * @param name - The display name to extract initials from + * @returns Up to 2 uppercase initials, or '?' if the name is empty + */ +function getInitials(name?: string): string { + if (!name) return '?'; + const parts = name.trim().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(); +} + +type HeaderProps = Pick< + OrganizationMemberDetailViewProps, + 'member' | 'styling' | 'customMessages' | 'handleBack' +>; + +/** + * Member detail header component + * @param root0 - Component props containing state and handlers + * @returns The rendered header element + */ +function Header({ member, styling, customMessages, handleBack }: HeaderProps): React.JSX.Element { + const { isDarkMode } = useTheme(); + const { t } = useTranslator('member_management', customMessages as Record); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + const memberRecord = member as Record | null; + const userId = (memberRecord?.user_id as string | undefined) ?? ''; + const displayName = (memberRecord?.name as string | undefined) ?? userId; + const initials = getInitials(displayName || undefined); + + return ( +
+ + +
+
+ {initials} +
+
+

{displayName}

+ {userId && ( + + {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, + showRemoveFromOrgModal, + isRemovingFromOrg, + showDeleteMemberModal, + isDeletingMember, + setActiveTab, + handleRemoveFromOrgCancel, + handleRemoveFromOrgConfirm, + handleDeleteMemberCancel, + handleDeleteMemberConfirm, + } = props; + + const { isDarkMode } = useTheme(); + const { t } = useTranslator('member_management', customMessages as Record); + + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + return ( + +
+
+ setActiveTab(value as 'details' | 'roles')} + className={currentStyles.classes?.['OrganizationMemberDetail-tabs']} + > + + {t('member.detail.tabs.details')} + {t('member.detail.tabs.roles')} + + + + + + + + + + + + + + +
+
+ ); +} + +/** + * 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, + deleteMemberAction, + assignRoleAction, + removeRoleAction, + } = props; + + const memberDetail = useOrganizationMemberDetail({ + userId, + onBack, + customMessages, + removeFromOrgAction, + deleteMemberAction, + assignRoleAction, + removeRoleAction, + }); + + return ( + + + + ); +} diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-user-details/member-detail-user-details.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-user-details/member-detail-user-details.tsx index 748c6b55..cabcbb59 100644 --- a/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-user-details/member-detail-user-details.tsx +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/members/member-user-details/member-detail-user-details.tsx @@ -5,10 +5,12 @@ */ import type { OrgMember } from '@auth0/universal-components-core'; +import { Copy } from 'lucide-react'; import * as React from 'react'; -import { CopyableTextField } from '@/components/auth0/shared/copyable-text-field'; +import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { OrganizationMemberDetailMessages } from '@/types/my-organization/member-management/organization-member-detail-types'; @@ -33,6 +35,60 @@ function formatDate(dateStr?: string): string { }); } +/** + * Displays a value with a copy-to-clipboard button. + * @param root0 - Component props + * @param root0.value - The string value to display and copy + * @param root0.copyLabel - Label for the copy button tooltip + * @param root0.copiedLabel - Label shown after copying + * @returns The rendered copyable value element + */ +function CopyableValue({ + value, + copyLabel, + copiedLabel, +}: { + value: string; + copyLabel: string; + copiedLabel: string; +}): React.JSX.Element { + const [tooltipOpen, setTooltipOpen] = React.useState(false); + const [tooltipText, setTooltipText] = React.useState(copyLabel); + + const handleCopy = async () => { + await navigator.clipboard.writeText(value); + setTooltipText(copiedLabel); + setTooltipOpen(true); + setTimeout(() => { + setTooltipText(copyLabel); + setTooltipOpen(false); + }, 1000); + }; + + return ( +
+ {value} + + + + + + {tooltipText} + + +
+ ); +} + /** * Renders the user details card for a member showing name, email, phone, and login timestamps. * @param root0 - Component props @@ -82,22 +138,28 @@ export function MemberDetailUserDetails({ ]; return ( - + <>

{t('member.detail.user_details.title')}

-
- {fields.map((field) => ( -
- {field.label} - {field.copyable && field.value !== '—' ? ( - - ) : ( - {field.value} - )} -
- ))} -
-
+ +
+ {fields.map((field) => ( +
+ {field.label} + {field.copyable && field.value !== '—' ? ( + + ) : ( + {field.value} + )} +
+ ))} +
+
+ ); } 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 00000000..765d43ca --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-details-tab.tsx @@ -0,0 +1,80 @@ +/** + * Organization member edit details tab. + * @module organization-member-details-tab + */ + +import * as React from 'react'; + +import { MemberDetailUserDetails } from '@/components/auth0/my-organization/shared/member-management/members/member-user-details/member-detail-user-details'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import type { OrganizationMemberDetailViewProps } from '@/types/my-organization/member-management/organization-member-detail-types'; + +type OrganizationMemberEditDetailsTabProps = Pick< + OrganizationMemberDetailViewProps, + 'member' | 'customMessages' | 'isRemovingFromOrg' | 'handleRemoveFromOrgClick' +>; + +/** + * Renders user details for the selected member. + * @param root0 - Component props containing member and customMessages + * @returns The rendered user details element, or null if member is not set + */ +function OrganizationMemberUserDetails({ + member, + customMessages, +}: OrganizationMemberEditDetailsTabProps): React.JSX.Element | null { + if (!member) return null; + return ; +} + +/** + * Card with a button to remove the member from the organization. + * @param root0 - Component props containing handlers and loading state + * @returns The rendered remove-from-org card element + */ +function RemoveFromOrganizationCard({ + customMessages, + isRemovingFromOrg, + handleRemoveFromOrgClick, +}: OrganizationMemberEditDetailsTabProps): React.JSX.Element { + const { t } = useTranslator('member_management', customMessages as Record); + return ( + +
+ + {t('member.detail.actions.remove_from_org.title')} + + + {t('member.detail.actions.remove_from_org.description')} + +
+ +
+ ); +} + +/** + * 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 ( +
+ + +
+ ); +} 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 00000000..e3a6e5cd --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/organization-member-detail/organization-member-roles-tab.tsx @@ -0,0 +1,248 @@ +/** + * Organization member edit roles tab. + * @module organization-member-roles-tab + */ + +import type { OrgMemberRole } from '@auth0/universal-components-core'; +import { Plus, Trash2 } from 'lucide-react'; +import * as React from 'react'; + +import { MemberAssignRolesModal } from '@/components/auth0/my-organization/shared/member-management/members/member-roles/member-assign-roles-modal'; +import { MemberRemoveRoleModal } from '@/components/auth0/my-organization/shared/member-management/members/member-roles/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 { RoleOption } from '@/types/my-organization/member-management/organization-invitation-table-types'; +import type { + OrganizationMemberDetailProps, + OrganizationMemberDetailViewProps, +} from '@/types/my-organization/member-management/organization-member-detail-types'; + +type OrganizationMemberEditRolesTabProps = Pick< + OrganizationMemberDetailViewProps, + | 'customMessages' + | 'memberRoles' + | 'availableRoles' + | 'isFetchingRoles' + | 'removingRoleId' + | 'showAssignRolesModal' + | 'isAssigningRole' + | 'showRemoveRoleModal' + | 'roleToRemove' + | 'handleAssignRolesClick' + | 'handleAssignRolesCancel' + | 'handleAssignRolesSubmit' + | 'handleRemoveRoleClick' + | 'handleRemoveRoleCancel' + | 'handleRemoveRoleConfirm' +>; + +interface RolesTabHeaderProps { + selectedRoles: OrgMemberRole[]; + customMessages: OrganizationMemberDetailProps['customMessages']; + onAssignRolesClick: () => void; + onRemoveSelectedRoles: () => void; +} + +/** + * Renders the header section of the roles tab with conditional action buttons. + * @param root0 - Component props + * @param root0.selectedRoles - The currently selected roles + * @param root0.customMessages - Optional custom message overrides + * @param root0.onAssignRolesClick - Handler for the assign roles button click + * @param root0.onRemoveSelectedRoles - Handler for removing all selected roles + * @returns The rendered roles tab header element + */ +function RolesTabHeader({ + selectedRoles, + customMessages, + onAssignRolesClick, + onRemoveSelectedRoles, +}: RolesTabHeaderProps): React.JSX.Element { + const { t } = useTranslator('member_management', customMessages as Record); + return ( +
+
+

{t('member.detail.roles.title')}

+

{t('member.detail.roles.description')}

+
+
+ {selectedRoles.length > 0 ? ( + <> + + {selectedRoles.length} {selectedRoles.length === 1 ? 'role' : 'roles'} selected + + + + ) : ( + + )} +
+
+ ); +} + +interface OrganizationMemberEditRolesTableProps { + memberRoles: OrgMemberRole[]; + availableRoles: RoleOption[]; + isLoading?: boolean; + removingRoleId?: string | null; + selectedRoles: OrgMemberRole[]; + customMessages: OrganizationMemberDetailProps['customMessages']; + onRemoveRole: (role: OrgMemberRole) => void; + onSelectedRolesChange: (roles: OrgMemberRole[]) => void; +} + +/** + * Renders the roles data table for the member detail roles tab. + * @param root0 - Component props + * @param root0.memberRoles - The list of roles assigned to the member + * @param root0.isLoading - Whether the roles data is loading + * @param root0.removingRoleId - The ID of the role currently being removed + * @param root0.selectedRoles - The currently selected roles + * @param root0.customMessages - Optional custom message overrides + * @param root0.onRemoveRole - Handler called when a role removal is requested + * @param root0.onSelectedRolesChange - Handler called when row selection changes + * @returns The rendered roles table element + */ +function OrganizationMemberEditRolesTable({ + memberRoles, + isLoading = false, + removingRoleId = null, + selectedRoles, + customMessages, + onRemoveRole, + onSelectedRolesChange, +}: OrganizationMemberEditRolesTableProps): React.JSX.Element { + const { t } = useTranslator('member_management', customMessages as Record); + + 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) => ( +
+ +
+ ), + }, + ], + [t, removingRoleId, onRemoveRole], + ); + + return ( + <> + role.id} + /> + + ); +} + +/** + * Roles tab — header, table, and role modals. + * @param root0 - Component props containing state and handlers + * @returns The rendered roles tab element + */ +export function OrganizationMemberEditRolesTab({ + customMessages, + memberRoles, + availableRoles, + isFetchingRoles, + removingRoleId, + showAssignRolesModal, + isAssigningRole, + showRemoveRoleModal, + roleToRemove, + handleAssignRolesClick, + handleAssignRolesCancel, + handleAssignRolesSubmit, + handleRemoveRoleClick, + handleRemoveRoleCancel, + handleRemoveRoleConfirm, +}: OrganizationMemberEditRolesTabProps): React.JSX.Element { + const [selectedRoles, setSelectedRoles] = React.useState([]); + + const handleRemoveSelectedRoles = React.useCallback(() => { + selectedRoles.forEach((role) => handleRemoveRoleClick(role)); + setSelectedRoles([]); + }, [selectedRoles, handleRemoveRoleClick]); + + return ( + <> + + + + + + + + + ); +} diff --git a/packages/react/src/components/auth0/shared/data-table.tsx b/packages/react/src/components/auth0/shared/data-table.tsx index b8f47440..7c44aca2 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; @@ -128,6 +129,14 @@ 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; } const ALIGNMENT_CLASSES = { @@ -398,10 +407,16 @@ export function DataTable({ headerAlign = 'left', onSortChange, sortConfig, + selectable = false, + selectedRows, + onSelectedRowsChange, + getRowId, }: 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 +426,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 +460,68 @@ 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="Select all rows" + onClick={(e: React.MouseEvent) => e.stopPropagation()} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!checked)} + aria-label={`Select row ${row.index + 1}`} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + /> + ), + }), + [], + ); + 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 +572,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 +641,7 @@ export function DataTable({ {table.getRowModel().rows.length === 0 ? ( - + [...memberDetailQueryKeys.all, 'member', id] as const, +}; + +type UseMemberDetailServiceOptions = Pick< + UseOrganizationMemberDetailOptions, + | 'userId' + | 'customMessages' + | 'removeFromOrgAction' + | 'deleteMemberAction' + | 'assignRoleAction' + | 'removeRoleAction' +>; + +/** + * 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, + deleteMemberAction, + assignRoleAction, + removeRoleAction, + } = options; + + const { coreClient } = useCoreClient(); + const { t } = useTranslator('member_management', customMessages as Record); + const queryClient = useQueryClient(); + + const memberQuery = useQuery({ + queryKey: memberDetailQueryKeys.member(userId), + queryFn: () => coreClient!.getMyOrganizationApiClient().organization.members.get(userId), + enabled: !!coreClient && !!userId, + }); + + const removeFromOrgMutation = useMutation({ + mutationFn: async () => { + if (removeFromOrgAction?.onBefore && !removeFromOrgAction.onBefore(userId)) { + throw new Error('Remove from org cancelled by onBefore'); + } + await coreClient! + .getMyOrganizationApiClient() + .organization.memberships.deleteMemberships({ members: [userId] }); + }, + onSuccess: () => { + removeFromOrgAction?.onAfter?.(userId); + showToast({ + type: 'success', + message: t('member.detail.danger_zone.remove_from_org.success'), + }); + }, + onError: () => { + showToast({ type: 'error', message: t('member.detail.error.remove_from_org_failed') }); + }, + }); + + const deleteMemberMutation = useMutation({ + mutationFn: async () => { + if (deleteMemberAction?.onBefore && !deleteMemberAction.onBefore(userId)) { + throw new Error('Delete member cancelled by onBefore'); + } + await coreClient! + .getMyOrganizationApiClient() + .organization.members.deleteMembers({ members: [userId] }); + }, + onSuccess: () => { + deleteMemberAction?.onAfter?.(userId); + showToast({ type: 'success', message: t('member.detail.danger_zone.delete_member.success') }); + }, + onError: () => { + showToast({ type: 'error', message: t('member.detail.error.delete_failed') }); + }, + }); + + const assignRoleMutation = useMutation({ + mutationFn: async (roleIds: string[]) => { + for (const roleId of roleIds) { + if (assignRoleAction?.onBefore && !assignRoleAction.onBefore({ userId, roleId })) { + throw new Error('Assign role cancelled by onBefore'); + } + } + await coreClient! + .getMyOrganizationApiClient() + .organization.members.roles.assign(userId, { role_ids: roleIds }); + for (const roleId of roleIds) { + assignRoleAction?.onAfter?.({ userId, roleId }); + } + }, + onSuccess: () => { + showToast({ type: 'success', message: t('member.detail.roles.assign_button') }); + queryClient.invalidateQueries({ queryKey: memberDetailQueryKeys.member(userId) }); + }, + onError: () => { + showToast({ type: 'error', message: t('member.detail.error.assign_role_failed') }); + }, + }); + + const removeRoleMutation = useMutation({ + mutationFn: async (role: OrgMemberRole) => { + if (removeRoleAction?.onBefore && !removeRoleAction.onBefore({ userId, roleId: role.id })) { + throw new Error('Remove role cancelled by onBefore'); + } + await coreClient! + .getMyOrganizationApiClient() + .organization.members.roles.unassign(userId, { role_ids: [role.id] }); + removeRoleAction?.onAfter?.({ userId, roleId: role.id }); + }, + onSuccess: () => { + showToast({ + type: 'success', + message: t('member.detail.roles.remove_confirm.confirm_button'), + }); + queryClient.invalidateQueries({ queryKey: memberDetailQueryKeys.member(userId) }); + }, + onError: () => { + showToast({ type: 'error', message: t('member.detail.error.remove_role_failed') }); + }, + }); + + return { + memberQuery, + removeFromOrgMutation, + deleteMemberMutation, + assignRoleMutation, + removeRoleMutation, + }; +} 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 00000000..52cdbac5 --- /dev/null +++ b/packages/react/src/hooks/my-organization/use-member-detail.ts @@ -0,0 +1,186 @@ +/** + * Organization member detail hook. + * @module use-member-detail + */ + +import type { OrgMember, OrgMemberRole } from '@auth0/universal-components-core'; +import * as React from 'react'; + +import { useMemberDetailService } from '@/hooks/my-organization/shared/services/use-member-detail-service'; +import { useConfig } from '@/hooks/my-organization/use-config'; +import type { RoleOption } from '@/types/my-organization/member-management/organization-invitation-table-types'; +import type { + MemberDetailTab, + UseOrganizationMemberDetailOptions, + UseOrganizationMemberDetailResult, +} from '@/types/my-organization/member-management/organization-member-detail-types'; + +export { memberDetailQueryKeys } from '@/hooks/my-organization/shared/services/use-member-detail-service'; + +/** + * 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, + deleteMemberAction, + assignRoleAction, + removeRoleAction, + } = options; + + const { allowedRoles } = useConfig(); + const availableRoles: RoleOption[] = allowedRoles; + + const { + memberQuery, + removeFromOrgMutation, + deleteMemberMutation, + assignRoleMutation, + removeRoleMutation, + } = useMemberDetailService({ + userId, + customMessages, + removeFromOrgAction, + deleteMemberAction, + assignRoleAction, + removeRoleAction, + }); + + const [activeTab, setActiveTab] = React.useState('details'); + const [showRemoveFromOrgModal, setShowRemoveFromOrgModal] = React.useState(false); + const [showDeleteMemberModal, setShowDeleteMemberModal] = React.useState(false); + const [showAssignRolesModal, setShowAssignRolesModal] = React.useState(false); + const [showRemoveRoleModal, setShowRemoveRoleModal] = React.useState(false); + const [roleToRemove, setRoleToRemove] = React.useState(null); + + const handleBack = React.useCallback(() => { + onBack?.(); + }, [onBack]); + + const handleRemoveFromOrgClick = React.useCallback(() => { + if (readOnly) return; + setShowRemoveFromOrgModal(true); + }, [readOnly]); + + const handleRemoveFromOrgConfirm = React.useCallback(() => { + removeFromOrgMutation.mutate(undefined, { + onSuccess: () => { + setShowRemoveFromOrgModal(false); + onBack?.(); + }, + }); + }, [removeFromOrgMutation, onBack]); + + const handleRemoveFromOrgCancel = React.useCallback(() => { + setShowRemoveFromOrgModal(false); + }, []); + + const handleDeleteMemberClick = React.useCallback(() => { + if (readOnly) return; + setShowDeleteMemberModal(true); + }, [readOnly]); + + const handleDeleteMemberConfirm = React.useCallback(() => { + deleteMemberMutation.mutate(undefined, { + onSuccess: () => { + setShowDeleteMemberModal(false); + onBack?.(); + }, + }); + }, [deleteMemberMutation, onBack]); + + const handleDeleteMemberCancel = React.useCallback(() => { + setShowDeleteMemberModal(false); + }, []); + + const handleAssignRolesClick = React.useCallback(() => { + if (readOnly) return; + setShowAssignRolesModal(true); + }, [readOnly]); + + const handleAssignRolesSubmit = React.useCallback( + (roleIds: string[]) => { + assignRoleMutation.mutate(roleIds, { + onSuccess: () => { + setShowAssignRolesModal(false); + }, + }); + }, + [assignRoleMutation], + ); + + const handleAssignRolesCancel = React.useCallback(() => { + setShowAssignRolesModal(false); + }, []); + + const handleRemoveRoleClick = React.useCallback( + (role: OrgMemberRole) => { + if (readOnly) return; + setRoleToRemove(role); + setShowRemoveRoleModal(true); + }, + [readOnly], + ); + + const handleRemoveRoleConfirm = React.useCallback(() => { + if (!roleToRemove) return; + removeRoleMutation.mutate(roleToRemove, { + onSuccess: () => { + setShowRemoveRoleModal(false); + setRoleToRemove(null); + }, + }); + }, [roleToRemove, removeRoleMutation]); + + const handleRemoveRoleCancel = React.useCallback(() => { + setShowRemoveRoleModal(false); + setRoleToRemove(null); + }, []); + + const member = (memberQuery.data as OrgMember) ?? null; + const memberRoles: OrgMemberRole[] = member?.roles ?? []; + + return { + activeTab, + member, + memberRoles, + availableRoles, + isFetchingMember: memberQuery.isLoading || memberQuery.isFetching, + isFetchingRoles: memberQuery.isLoading || memberQuery.isFetching, + isLoading: memberQuery.isLoading, + isRemovingFromOrg: removeFromOrgMutation.isPending, + isDeletingMember: deleteMemberMutation.isPending, + isAssigningRole: assignRoleMutation.isPending, + removingRoleId: removeRoleMutation.isPending ? (roleToRemove?.id ?? null) : null, + showRemoveFromOrgModal, + showDeleteMemberModal, + showAssignRolesModal, + showRemoveRoleModal, + roleToRemove, + + setActiveTab, + handleBack, + handleRemoveFromOrgClick, + handleRemoveFromOrgConfirm, + handleRemoveFromOrgCancel, + handleDeleteMemberClick, + handleDeleteMemberConfirm, + handleDeleteMemberCancel, + handleAssignRolesClick, + handleAssignRolesSubmit, + handleAssignRolesCancel, + handleRemoveRoleClick, + handleRemoveRoleConfirm, + handleRemoveRoleCancel, + }; +} + +export type { UseOrganizationMemberDetailResult }; 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 02aa5053..d0323587 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 @@ -105,6 +105,25 @@ 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', + }), + deleteMembers: vi.fn().mockResolvedValue(undefined), + 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/types/my-organization/member-management/organization-member-detail-types.ts b/packages/react/src/types/my-organization/member-management/organization-member-detail-types.ts index 20ab65ae..43175c78 100644 --- 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 @@ -9,11 +9,20 @@ import type { OrgMember, OrgMemberRole, } from '@auth0/universal-components-core'; +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; import type { RoleOption } from './organization-invitation-table-types'; export type MemberDetailTab = 'details' | 'roles'; +export interface MemberDetailServiceResult { + memberQuery: UseQueryResult; + removeFromOrgMutation: UseMutationResult; + deleteMemberMutation: UseMutationResult; + assignRoleMutation: UseMutationResult; + removeRoleMutation: UseMutationResult; +} + export interface UseOrganizationMemberDetailOptions { userId: string; onBack?: () => void; @@ -158,3 +167,13 @@ export interface OrganizationMemberDetailProps assignRoleAction?: ComponentAction<{ userId: string; roleId: string }>; removeRoleAction?: ComponentAction<{ userId: string; roleId: string }>; } + +export interface UseOrganizationMemberDetailResult + extends MemberDetailState, + MemberDetailHandlers {} + +/** Props for OrganizationMemberDetailView component. */ +export interface OrganizationMemberDetailViewProps extends UseOrganizationMemberDetailResult { + styling: OrganizationMemberDetailProps['styling']; + customMessages: OrganizationMemberDetailProps['customMessages']; +}