diff --git a/auth0-myorganization-js-1.0.0-beta.4.tgz b/auth0-myorganization-js-1.0.0-beta.4.tgz new file mode 100644 index 000000000..b6675e14d Binary files /dev/null and b/auth0-myorganization-js-1.0.0-beta.4.tgz differ diff --git a/packages/core/package.json b/packages/core/package.json index 31755887c..d7e3ae79d 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.0-beta.4.tgz", "zod": "^3.22.4" } } diff --git a/packages/core/src/services/my-organization/index.ts b/packages/core/src/services/my-organization/index.ts index deb30e8e6..5e1816d52 100644 --- a/packages/core/src/services/my-organization/index.ts +++ b/packages/core/src/services/my-organization/index.ts @@ -7,4 +7,5 @@ export * from './organization-management'; export * from './idp-management'; export * from './domain-management'; +export * from './member-management/member-management-types'; export * from './config'; 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..7c7561743 --- /dev/null +++ b/packages/core/src/services/my-organization/member-management/member-management-types.ts @@ -0,0 +1,111 @@ +/** + * 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 entity. + */ +export type OrgMember = MyOrganization.OrgMember; + +/** + * Organization member role. + */ +export type OrgMemberRole = MyOrganization.OrgMemberRole; + +/** + * Organization member role ID. + */ +export type OrgMemberRoleId = MyOrganization.OrgMemberRoleId; + +/** + * 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; + +/** + * Request content for assigning a role to an organization member. + */ +export type AssignOrganizationMemberRoleRequestContent = + MyOrganization.AssignOrganizationMemberRoleRequestContent; + +/** + * Response content for assigning a role to an organization member. + */ +export type AssignOrganizationMemberRoleResponseContent = + MyOrganization.AssignOrganizationMemberRoleResponseContent; + +/** + * 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; 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..c0bcb91ad --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal.tsx @@ -0,0 +1,236 @@ +/** + * Organization invitation details modal component. + * @module organization-invitation-details-modal + */ + +import { Link } from 'lucide-react'; +import * as React from 'react'; + +import { CopyableTextField } from '@/components/auth0/shared/copyable-text-field'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + 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'; + +export type { OrganizationInvitationDetailsModalProps }; + +/** + * 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 handleCopyUrl = React.useCallback(() => { + if (invitation) { + onCopyUrl?.(invitation); + } + }, [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 */} +
+ + +
+ + {/* Created At */} +
+ + +
+ + {/* Expires At */} +
+ + +
+ + {/* Roles */} +
+ + {roleNames.length > 0 ? ( + ({ label: name, value: name }))} + summarizeChips={false} + disabled + readOnly + /> + ) : ( + + )} +
+ + {/* Invitation URL */} + {invitation?.invitation_url && ( +
+ + } + /> +
+ )} + + {/* Revoke / Resend Actions (inline, below invitation URL) */} + {!readOnly && ( +
+ + +
+ )} + + {/* Invited By */} +
+ + +
+ + {/* Identity Provider */} + {providerName && ( +
+ + +
+ )} +
+ + + + +
+
+ ); +} 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..25a926278 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal.tsx @@ -0,0 +1,84 @@ +/** + * 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'; + +export type { OrganizationInvitationRevokeModalProps }; + +/** + * 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 ?? '' }, + })} + + + + + + + + + ); +} 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..dae2372b9 --- /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,110 @@ +/** + * 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 { 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 handleViewDetails = React.useCallback(() => { + onViewDetails?.(invitation); + }, [invitation, onViewDetails]); + + const handleCopyUrl = React.useCallback(() => { + onCopyUrl?.(invitation); + }, [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')} + + + {/* Copy URL - only for pending invitations with URL */} + {isPending && invitation.invitation_url && ( + + + {t('invitation.actions.copy_url')} + + )} + + {!readOnly && ( + <> + + + {t('invitation.actions.revoke_and_resend')} + + + + + {t('invitation.actions.revoke')} + + + )} + + + +
+ ); +} 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..f68ba2ef0 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table.tsx @@ -0,0 +1,188 @@ +/** + * 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.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, + 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'), + enableSorting: false, + render: (invitation) => ( +
{invitation.invitee?.email}
+ ), + }, + { + type: 'text', + accessorKey: 'organization_id', + title: t('invitation.table.columns.status'), + 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 ( +
+ + + + + {invitations.length > 0 && ( +
+ +
+ )} +
+ ); +} 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..339821bf8 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal.tsx @@ -0,0 +1,278 @@ +/** + * 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'; + +export type { OrganizationInvitationCreateModalProps }; + +/** + * 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 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( + (e: React.FormEvent) => { + e.preventDefault(); + const finalEmails = emailChips + .filter((chip) => chip.variant !== 'destructive') + .map((chip) => chip.value); + + if (emailInput.trim()) { + const trimmedEmail = emailInput.trim(); + const result = validationConfig.emailSchema.safeParse(trimmedEmail); + if (result.success && !finalEmails.includes(trimmedEmail)) { + finalEmails.push(trimmedEmail); + } + } + + if (finalEmails.length === 0) { + setEmailError(t('invitation.create.email_required_error')); + return; + } + + onCreate({ + invitees: finalEmails.map((email) => ({ + email, + roles: selectedRoles.length > 0 ? selectedRoles : undefined, + })), + identity_provider_id: selectedProvider, + ...(inviterName && { inviter: { name: inviterName } }), + }); + }, + [ + emailChips, + emailInput, + validationConfig, + selectedRoles, + selectedProvider, + inviterName, + onCreate, + t, + ], + ); + + const handleClose = React.useCallback(() => { + setEmailInput(''); + setEmailChips([]); + setSelectedRoles([]); + setSelectedProvider(undefined); + setEmailError(undefined); + onClose(); + }, [onClose]); + + 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')} + + +
+ {/* Email Input */} +
+ + +

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

+ {emailError &&

{emailError}

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

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

+
+
+ + + + + +
+
+
+ ); +} 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..62da9a66f --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/search-filter/search-filter.tsx @@ -0,0 +1,86 @@ +/** + * 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 { + 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.onRoleFilterChange - Callback fired when role filter changes. + * @returns The filter bar component. + */ +export function SearchFilter({ + filters, + availableRoles = [], + customMessages = {}, + className, + onRoleFilterChange, +}: SearchFilterProps): React.JSX.Element | null { + const { t } = useTranslator('member_management', customMessages); + + const handleRoleFilterChange = React.useCallback( + (value: string) => { + onRoleFilterChange?.(value === 'all' ? undefined : value); + }, + [onRoleFilterChange], + ); + + const handleReset = React.useCallback(() => { + onRoleFilterChange?.(undefined); + }, [onRoleFilterChange]); + + const hasActiveFilter = !!filters?.roleId; + + if (availableRoles.length === 0) { + return null; + } + + return ( +
+ + + +
+ ); +} 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..8db62fd1e --- /dev/null +++ b/packages/react/src/lib/utils/my-organization/member-management/member-management-utils.ts @@ -0,0 +1,20 @@ +/** + * Member management utility functions. + * @module member-management-utils + * @internal + */ + +import type { MemberInvitation } from '@auth0/universal-components-core'; + +import type { InvitationStatus } from '@/types/my-organization/member-management/organization-invitation-table-types'; + +/** + * 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'; +} diff --git a/packages/react/src/types/index.ts b/packages/react/src/types/index.ts index b46dc7304..852a42fec 100644 --- a/packages/react/src/types/index.ts +++ b/packages/react/src/types/index.ts @@ -28,3 +28,5 @@ 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'; 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..103e91c8c --- /dev/null +++ b/packages/react/src/types/my-organization/member-management/organization-invitation-table-types.ts @@ -0,0 +1,172 @@ +/** + * Organization invitation table types. + * @module organization-invitation-table-types + */ + +import type { + SharedComponentProps, + ComponentAction, + MemberInvitation, + InvitationCreateSchemas, + OrganizationInvitationTabMessages, +} from '@auth0/universal-components-core'; + +/** Invitation status. */ +export type InvitationStatus = 'pending' | 'expired'; + +/** Role option for invitation. */ +export interface RoleOption { + id: string; + name: string; + description?: string; +} + +/** Identity provider option for invitation. */ +export interface IdentityProviderOption { + id: string; + 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; +} + +/** Pagination state for invitation table (checkpoint-based). */ +export interface InvitationPaginationState { + pageSize: number; + currentPage: number; + totalItems?: number; + hasNextPage: boolean; + hasPreviousPage: boolean; +} + +/** Sort configuration for invitation table. */ +export interface InvitationSortConfig { + key: string | null; + direction: 'asc' | 'desc'; +} + +/** Filter state for invitation table. */ +export interface InvitationFilterState { + searchQuery?: string; + roleId?: string; +} + +/** 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: InvitationPaginationState; + filters?: InvitationFilterState; + sortConfig?: InvitationSortConfig; + availableRoles?: RoleOption[]; + 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: InvitationSortConfig) => void; + onRoleFilterChange?: (roleId: string | undefined) => void; + className?: string; +} + +/** Props for SearchFilter component. */ +export interface SearchFilterProps { + filters?: InvitationFilterState; + availableRoles?: RoleOption[]; + customMessages?: Partial; + className?: string; + onRoleFilterChange?: (roleId: string | undefined) => void; +} + +/** Props for OrganizationInvitationCreateModal component. */ +export interface OrganizationInvitationCreateModalProps { + isOpen: boolean; + isLoading?: boolean; + customMessages?: Partial; + availableRoles?: RoleOption[]; + 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?: RoleOption[]; + 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-management-types.ts b/packages/react/src/types/my-organization/member-management/organization-member-management-types.ts new file mode 100644 index 000000000..aa12341c7 --- /dev/null +++ b/packages/react/src/types/my-organization/member-management/organization-member-management-types.ts @@ -0,0 +1,132 @@ +/** + * Organization member management types. + * @module organization-member-management-types + */ + +import type { + ComponentAction, + SharedComponentProps, + MemberInvitation, + OrganizationMemberManagementMessages, +} from '@auth0/universal-components-core'; +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; + +import type { + CreateInvitationInput, + IdentityProviderOption, + InvitationFilterState, + InvitationPaginationState, + InvitationSortConfig, + OrganizationInvitationTabClasses, + RoleOption, +} from './organization-invitation-table-types'; + +export type ActiveTab = 'members' | 'invitations'; + +export interface TableQueryParams { + pageSize: number; + fromToken: string | undefined; + sortConfig: TSort; + filters: TFilter; +} + +export interface UseMemberManagementServiceOptions { + customMessages?: OrganizationMemberManagementMessages; + activeTab: ActiveTab; + createInvitationAction?: ComponentAction; + revokeInvitationAction?: ComponentAction; + resendInvitationAction?: ComponentAction; + invitationParams: TableQueryParams; +} + +export interface MemberManagementServiceResult { + providersQuery: UseQueryResult; + invitationsQuery: UseQueryResult<{ + invitations: MemberInvitation[]; + next: string | null; + total: number | undefined; + }>; + createInvitationMutation: UseMutationResult< + MemberInvitation | undefined, + Error, + CreateInvitationInput + >; + revokeInvitationMutation: UseMutationResult; + resendInvitationMutation: UseMutationResult< + MemberInvitation | undefined, + Error, + MemberInvitation + >; + fetchInvitationDetails: (invitationId: string) => Promise; +} + +export interface UseOrganizationMemberManagementOptions { + customMessages?: OrganizationMemberManagementMessages; + 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; +} + +/** 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 }; + +export interface UseOrganizationMemberManagementResult { + activeTab: ActiveTab; + isLoading: boolean; + availableRoles: RoleOption[]; + availableProviders: IdentityProviderOption[]; + + invitations: MemberInvitation[]; + isFetchingInvitations: boolean; + isCreatingInvitation: boolean; + isRevokingInvitation: boolean; + isResendingInvitation: boolean; + invitationPagination: InvitationPaginationState; + invitationFilters: InvitationFilterState; + invitationSortConfig: InvitationSortConfig; + modalState: MemberManagementModalState; + + 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: InvitationSortConfig) => void; + handleRoleFilterChange: (roleId: string | undefined) => void; +} + +/** 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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34cd1533f..8d6842bd3 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.0-beta.4.tgz + version: file:auth0-myorganization-js-1.0.0-beta.4.tgz zod: specifier: ^3.22.4 version: 3.25.76 @@ -733,9 +733,10 @@ packages: resolution: {integrity: sha512-slj0RtNfieNk1BC1ERrCQw65qMUVKU5qacbTc8BFH8R316CUpsOhZ2MIiV9l3VEkaqY1hmCZm03+ZI6ym+3PZg==} engines: {node: '>=18.0.0'} - '@auth0/myorganization-js@1.0.0': - resolution: {integrity: sha512-mYGa95tFj3xgUKKVSi4B95Yt4FPppFfbtmWM9fvXUEgwSgmLHre6vHLwcnsXTPB/rF7ATpAtMMIsWq1N5h9Y4w==} - engines: {node: '>=20.0.0'} + '@auth0/myorganization-js@file:auth0-myorganization-js-1.0.0-beta.4.tgz': + resolution: {integrity: sha512-yTZbJ0K75WsCGsb9bXBGTs32WLXEXqyTuM9lgmYST8uSag2ILfJdYZPQZkvyKM5v+7Sk5B9vz0mg3tNfv8SFNg==, tarball: file:auth0-myorganization-js-1.0.0-beta.4.tgz} + version: 1.0.0-beta.4 + engines: {node: '>=18.0.0'} '@auth0/nextjs-auth0@4.16.0': resolution: {integrity: sha512-QTQdK+/YL68J7b1tSdvJTT16+r+Dxwy/m0kwl73CEy/QsYCpHE1sCZYgQ4UaFZb5jHQ7d4R2JUkHN5k2fKQ4zg==} @@ -7363,7 +7364,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.0-beta.4.tgz': dependencies: '@auth0/auth0-auth-js': 1.5.0