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/auth0-myorganization-js-1.0.0-beta.5.tgz b/auth0-myorganization-js-1.0.0-beta.5.tgz new file mode 100644 index 000000000..75ecbc4e8 Binary files /dev/null and b/auth0-myorganization-js-1.0.0-beta.5.tgz differ diff --git a/examples/next-rwa/src/app/member-management/page.tsx b/examples/next-rwa/src/app/member-management/page.tsx new file mode 100644 index 000000000..9e354e616 --- /dev/null +++ b/examples/next-rwa/src/app/member-management/page.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { OrganizationMemberManagement } from '@auth0/universal-components-react/rwa'; + +export default function MemberManagementPage() { + return ( +
+

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

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

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

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

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

    +
    + {/* */} +
    +
    + ); +}; + +export default MemberManagement; diff --git a/examples/scripts/utils/env-writer.mjs b/examples/scripts/utils/env-writer.mjs index 9b4d573fd..52eee8c88 100644 --- a/examples/scripts/utils/env-writer.mjs +++ b/examples/scripts/utils/env-writer.mjs @@ -72,6 +72,8 @@ const MYORG_SCOPES = [ "delete:my_org:domains", "create:my_org:domains", "update:my_org:domains", + "read:my_org:member_invitations", + "delete:my_org:member_invitations" ] // My Account API scopes diff --git a/examples/scripts/utils/resource-servers.mjs b/examples/scripts/utils/resource-servers.mjs index d7eeb6b91..95fe0c88d 100644 --- a/examples/scripts/utils/resource-servers.mjs +++ b/examples/scripts/utils/resource-servers.mjs @@ -26,6 +26,8 @@ export const MYORG_API_SCOPES = [ "read:my_org:identity_providers_provisioning", "delete:my_org:identity_providers_provisioning", "read:my_org:configuration", +"read:my_org:member_invitations", +"delete:my_org:member_invitations" ] // My Account API Scopes - desired scopes for MFA management diff --git a/packages/core/package.json b/packages/core/package.json index 497130fe8..f53614c5f 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-beta.6", + "@auth0/myorganization-js": "../../auth0-myorganization-js-1.0.0-beta.5.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 27bc62393..845ecef45 100644 --- a/packages/core/src/i18n/custom-messages/my-organization/index.ts +++ b/packages/core/src/i18n/custom-messages/my-organization/index.ts @@ -7,3 +7,5 @@ export * from './idp-management'; export * from './organization-management'; export * from './domain-management'; +export * from './member-management/invitation-tab-types'; +export * from './member-management/member-management-types'; diff --git a/packages/core/src/i18n/custom-messages/my-organization/member-management/invitation-tab-types.ts b/packages/core/src/i18n/custom-messages/my-organization/member-management/invitation-tab-types.ts new file mode 100644 index 000000000..e51228669 --- /dev/null +++ b/packages/core/src/i18n/custom-messages/my-organization/member-management/invitation-tab-types.ts @@ -0,0 +1,90 @@ +/** + * Custom message type definitions for invitation tab. + * @module invitation-tab-types + * @internal + */ + +export interface OrganizationInvitationTabMessages { + table?: { + columns?: { + email?: string; + status?: string; + inviter?: string; + created_at?: string; + expires_at?: string; + roles?: string; + }; + empty_message?: string; + search_placeholder?: string; + filter_by_role?: string; + all_roles?: string; + reset_filter?: string; + showing_results?: string; + status_pending?: string; + status_expired?: string; + }; + actions?: { + menu_label?: string; + view_details?: string; + copy_url?: string; + revoke_and_resend?: string; + revoke?: string; + }; + create?: { + title?: string; + description?: string; + email_label?: string; + email_placeholder?: string; + email_helper?: string; + email_limit_error?: string; + email_invalid_error?: string; + email_duplicate_error?: string; + email_required_error?: string; + roles_label?: string; + roles_placeholder?: string; + provider_label?: string; + provider_placeholder?: string; + submit_button?: string; + creating?: string; + cancel_button?: string; + }; + details?: { + title?: string; + email_label?: string; + status_label?: string; + roles_label?: string; + provider_label?: string; + created_at_label?: string; + expires_at_label?: string; + invited_by_label?: string; + invitation_url_label?: string; + copy_url_button?: string; + close_button?: string; + revoke_button?: string; + resend_button?: string; + }; + revoke?: { + title?: string; + description?: string; + confirm_button?: string; + cancel_button?: string; + }; + revoke_resend?: { + title?: string; + description?: string; + confirm_button?: string; + cancel_button?: string; + }; + success?: { + url_copied?: string; + invitation_resent?: string; + }; + error?: { + fetch_failed?: string; + create_failed?: string; + revoke_failed?: string; + resend_failed?: string; + revoke_resend_failed?: string; + copy_url_failed?: string; + }; +} 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 new file mode 100644 index 000000000..60bfc9796 --- /dev/null +++ b/packages/core/src/i18n/custom-messages/my-organization/member-management/member-management-types.ts @@ -0,0 +1,19 @@ +/** + * Custom message type definitions for member management. + * @module member-management-types + * @internal + */ + +import type { OrganizationInvitationTabMessages } from './invitation-tab-types'; + +export interface OrganizationMemberManagementMessages { + header?: { + title?: string; + description?: string; + }; + tabs?: { + members?: string; + invitations?: string; + }; + invitation?: OrganizationInvitationTabMessages; +} diff --git a/packages/core/src/i18n/translations/en-US.json b/packages/core/src/i18n/translations/en-US.json index 2cd7c8218..2cfc2478d 100644 --- a/packages/core/src/i18n/translations/en-US.json +++ b/packages/core/src/i18n/translations/en-US.json @@ -1069,5 +1069,124 @@ "install_guardian_description": "In order to continue, install the Auth0 Guardian app via the app store from your mobile device" } } + }, + "member_management": { + "header": { + "title": "Members", + "description": "Manage the members of your organization." + }, + "invite_button": "Invite Member", + "tabs": { + "members": "Members", + "invitations": "Invitations" + }, + "member": { + "table": { + "columns": { + "name": "Name", + "email": "Email", + "roles": "Roles" + }, + "empty_message": "No members found." + }, + "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." + }, + "error": { + "fetch_failed": "Failed to load members. Please try again.", + "remove_failed": "Failed to remove member. Please try again." + } + }, + "invitation": { + "table": { + "columns": { + "email": "Email", + "status": "Status", + "inviter": "Invited By", + "created_at": "Created At", + "expires_at": "Expires At", + "roles": "Roles" + }, + "empty_message": "No pending invitations.", + "search_placeholder": "Search by email...", + "filter_by_role": "Filter By Role", + "all_roles": "All", + "reset_filter": "Reset", + "showing_results": "Showing ${start}-${end} of ${total}", + "status_pending": "Pending", + "status_expired": "Expired" + }, + "actions": { + "menu_label": "Actions", + "view_details": "View Details", + "copy_url": "Copy Invitation URL", + "revoke_and_resend": "Revoke and Resend", + "revoke": "Revoke Invitation" + }, + "create": { + "title": "Invite Member", + "description": "Send an invitation to join this organization.", + "email_label": "Email Address", + "email_placeholder": "Enter email address and press Enter", + "email_helper": "Add up to 10 members in a comma-separated list.", + "email_limit_error": "You can add up to 10 email addresses.", + "email_invalid_error": "Please enter a valid email address.", + "email_duplicate_error": "This email has already been added.", + "email_required_error": "Please enter at least one email address.", + "roles_label": "Roles", + "roles_placeholder": "Select roles (optional)", + "provider_label": "Provider", + "provider_placeholder": "Select provider (optional)", + "provider_helper": "If none is selected, the member can log in with any provider.", + "submit_button": "Send Invite", + "creating": "Creating...", + "cancel_button": "Cancel", + "success": "Invitation sent to ${email}." + }, + "details": { + "title": "Invitation Details", + "email_label": "Email", + "status_label": "Status", + "roles_label": "Roles", + "provider_label": "Identity Provider", + "created_at_label": "Created", + "expires_at_label": "Expires", + "invited_by_label": "Invited By", + "invitation_url_label": "Invitation URL", + "copy_url_button": "Copy", + "close_button": "Close", + "revoke_button": "Revoke Invitation", + "resend_button": "Revoke and Resend" + }, + "revoke": { + "title": "Revoke Invitation", + "description": "Are you sure you want to revoke the invitation to ${email}?", + "confirm_button": "Revoke", + "cancel_button": "Cancel", + "success": "Invitation for ${email} has been revoked." + }, + "revoke_resend": { + "title": "Revoke and Resend Invitation", + "description": "Are you sure you want to revoke the current invitation and send a new one to ${email}?", + "confirm_button": "Proceed", + "cancel_button": "Cancel" + }, + "success": { + "url_copied": "Invitation URL copied to clipboard.", + "invitation_resent": "Invitation resent to ${email}." + }, + "error": { + "fetch_failed": "Failed to load invitations. Please try again.", + "create_failed": "Failed to send invitation. Please try again.", + "revoke_failed": "Failed to revoke invitation. Please try again.", + "resend_failed": "Failed to resend invitation. Please try again.", + "revoke_resend_failed": "Failed to revoke and resend invitation. Please try again.", + "copy_url_failed": "Failed to copy invitation URL. Please try again." + } + } } } diff --git a/packages/core/src/i18n/translations/ja.json b/packages/core/src/i18n/translations/ja.json index 73bc5d62b..a877c73a4 100644 --- a/packages/core/src/i18n/translations/ja.json +++ b/packages/core/src/i18n/translations/ja.json @@ -1071,5 +1071,124 @@ "install_guardian_description": "続行するには、お使いのモバイルデバイスからアプリストア経由でAuth0 Guardianアプリをインストールしてください。" } } + }, + "member_management": { + "header": { + "title": "メンバー", + "description": "組織のメンバーを管理します。" + }, + "invite_button": "メンバーを招待", + "tabs": { + "members": "メンバー", + "invitations": "招待" + }, + "member": { + "table": { + "columns": { + "name": "名前", + "email": "メール", + "roles": "ロール" + }, + "empty_message": "メンバーが見つかりません。" + }, + "remove": { + "title": "メンバーを削除", + "description": "この組織から${name}を削除してもよろしいですか?", + "confirm_button": "削除", + "cancel_button": "キャンセル", + "success": "${name}が組織から削除されました。" + }, + "error": { + "fetch_failed": "メンバーの読み込みに失敗しました。もう一度お試しください。", + "remove_failed": "メンバーの削除に失敗しました。もう一度お試しください。" + } + }, + "invitation": { + "table": { + "columns": { + "email": "メール", + "status": "ステータス", + "inviter": "招待者", + "created_at": "作成日", + "expires_at": "有効期限", + "roles": "ロール" + }, + "empty_message": "保留中の招待はありません。", + "search_placeholder": "メールで検索...", + "filter_by_role": "ロールで絞り込み", + "all_roles": "すべて", + "reset_filter": "リセット", + "showing_results": "${start}〜${end} / ${total}件", + "status_pending": "保留中", + "status_expired": "期限切れ" + }, + "actions": { + "menu_label": "アクション", + "view_details": "詳細を表示", + "copy_url": "招待URLをコピー", + "revoke_and_resend": "取り消して再送信", + "revoke": "招待を取り消す" + }, + "create": { + "title": "メンバーを招待", + "description": "この組織に参加するための招待を送信します。", + "email_label": "メールアドレス", + "email_placeholder": "メールアドレスを入力してEnterを押してください", + "email_helper": "カンマ区切りで最大10名まで追加できます。", + "email_limit_error": "メールアドレスは最大10件まで追加できます。", + "email_invalid_error": "有効なメールアドレスを入力してください。", + "email_duplicate_error": "このメールアドレスは既に追加されています。", + "email_required_error": "メールアドレスを1つ以上入力してください。", + "roles_label": "ロール", + "roles_placeholder": "ロールを選択(任意)", + "provider_label": "プロバイダー", + "provider_placeholder": "プロバイダーを選択(任意)", + "provider_helper": "選択しない場合、メンバーは任意のプロバイダーでログインできます。", + "submit_button": "招待を送信", + "creating": "作成中...", + "cancel_button": "キャンセル", + "success": "${email}に招待を送信しました。" + }, + "details": { + "title": "招待の詳細", + "email_label": "メール", + "status_label": "ステータス", + "roles_label": "ロール", + "provider_label": "IDプロバイダー", + "created_at_label": "作成日", + "expires_at_label": "有効期限", + "invited_by_label": "招待者", + "invitation_url_label": "招待URL", + "copy_url_button": "コピー", + "close_button": "閉じる", + "revoke_button": "招待を取り消す", + "resend_button": "取り消して再送信" + }, + "revoke": { + "title": "招待を取り消す", + "description": "${email}への招待を取り消してもよろしいですか?", + "confirm_button": "取り消す", + "cancel_button": "キャンセル", + "success": "${email}への招待が取り消されました。" + }, + "revoke_resend": { + "title": "招待を取り消して再送信", + "description": "現在の招待を取り消し、${email}に新しい招待を送信してもよろしいですか?", + "confirm_button": "続行", + "cancel_button": "キャンセル" + }, + "success": { + "url_copied": "招待URLをクリップボードにコピーしました。", + "invitation_resent": "${email}に招待を再送信しました。" + }, + "error": { + "fetch_failed": "招待の読み込みに失敗しました。もう一度お試しください。", + "create_failed": "招待の送信に失敗しました。もう一度お試しください。", + "revoke_failed": "招待の取り消しに失敗しました。もう一度お試しください。", + "resend_failed": "招待の再送信に失敗しました。もう一度お試しください。", + "revoke_resend_failed": "招待の取り消しと再送信に失敗しました。もう一度お試しください。", + "copy_url_failed": "招待URLのコピーに失敗しました。もう一度お試しください。" + } + } } } diff --git a/packages/core/src/schemas/my-organization/index.ts b/packages/core/src/schemas/my-organization/index.ts index 28b4269df..c800eec7e 100644 --- a/packages/core/src/schemas/my-organization/index.ts +++ b/packages/core/src/schemas/my-organization/index.ts @@ -7,3 +7,6 @@ export * from './organization-management'; export * from './idp-management'; export * from './domain-management'; +export * from './member-management/invitations/invitation-schema'; +export * from './member-management/invitations/invitation-create-schema'; +export * from './member-management/invitations/invitation-create-schema-types'; diff --git a/packages/core/src/schemas/my-organization/member-management/invitations/__tests__/invitation-create-schema.test.ts b/packages/core/src/schemas/my-organization/member-management/invitations/__tests__/invitation-create-schema.test.ts new file mode 100644 index 000000000..e24e90ec0 --- /dev/null +++ b/packages/core/src/schemas/my-organization/member-management/invitations/__tests__/invitation-create-schema.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; + +import { + createInvitationCreateSchema, + invitationCreateSchemaDefaults, +} from '../invitation-create-schema'; + +describe('Invitation Create Schema', () => { + describe('default schema', () => { + describe.each([ + { input: 'user@example.com', shouldPass: true, description: 'simple email' }, + { input: 'test.user@domain.com', shouldPass: true, description: 'email with dots' }, + { input: 'user+tag@example.com', shouldPass: true, description: 'email with plus tag' }, + { input: 'user@sub.domain.com', shouldPass: true, description: 'email with subdomain' }, + { input: 'a@b.co', shouldPass: true, description: 'minimal valid email' }, + { + input: 'user-name@example.org', + shouldPass: true, + description: 'email with hyphen in local', + }, + { + input: 'user_name@example.org', + shouldPass: true, + description: 'email with underscore in local', + }, + ])('when email is "$input" ($description)', ({ input, shouldPass }) => { + it(`should ${shouldPass ? 'accept' : 'reject'}`, () => { + const result = invitationCreateSchemaDefaults.emailSchema.safeParse(input); + expect(result.success).toBe(shouldPass); + }); + }); + + describe.each([ + { input: '', shouldPass: false, description: 'empty string' }, + { input: ' ', shouldPass: false, description: 'whitespace only' }, + { input: 'notanemail', shouldPass: false, description: 'missing @ and domain' }, + { input: '@example.com', shouldPass: false, description: 'missing local part' }, + { input: 'user@', shouldPass: false, description: 'missing domain' }, + { input: 'user@.com', shouldPass: false, description: 'missing domain name' }, + { input: 'user @example.com', shouldPass: false, description: 'space in local part' }, + ])('when email is "$input" ($description)', ({ input, shouldPass }) => { + it(`should ${shouldPass ? 'accept' : 'reject'}`, () => { + const result = invitationCreateSchemaDefaults.emailSchema.safeParse(input); + expect(result.success).toBe(shouldPass); + }); + }); + + it('should return the default error message on validation failure', () => { + const result = invitationCreateSchemaDefaults.emailSchema.safeParse(''); + expect(result.success).toBe(false); + if (!result.success && result.error?.errors[0]) { + expect(result.error.errors[0].message).toBe('Please enter a valid email address'); + } + }); + + it('should have default maxEmails of 10', () => { + expect(invitationCreateSchemaDefaults.maxEmails).toBe(10); + }); + + it('should have default emailRegex', () => { + expect(invitationCreateSchemaDefaults.emailRegex).toBeInstanceOf(RegExp); + expect(invitationCreateSchemaDefaults.emailRegex.test('user@example.com')).toBe(true); + }); + }); + + describe('createInvitationCreateSchema factory', () => { + describe('with custom error message', () => { + it('should use custom default error message', () => { + const customMessage = 'Custom email validation error'; + const schema = createInvitationCreateSchema({}, customMessage); + const result = schema.emailSchema.safeParse(''); + + expect(result.success).toBe(false); + if (!result.success && result.error?.errors[0]) { + expect(result.error.errors[0].message).toBe(customMessage); + } + }); + + it('should use field-level error message over default', () => { + const fieldError = 'Field-level error'; + const defaultError = 'Default error'; + const schema = createInvitationCreateSchema( + { email: { errorMessage: fieldError } }, + defaultError, + ); + const result = schema.emailSchema.safeParse('invalid'); + + expect(result.success).toBe(false); + if (!result.success && result.error?.errors[0]) { + expect(result.error.errors[0].message).toBe(fieldError); + } + }); + }); + + describe('with custom regex', () => { + it('should accept emails matching the custom regex', () => { + const customRegex = /^[\w.+-]+@auth0\.com$/; + const schema = createInvitationCreateSchema({ + email: { + regex: customRegex, + errorMessage: 'Only @auth0.com emails allowed', + }, + }); + + expect(schema.emailSchema.safeParse('user@auth0.com').success).toBe(true); + expect(schema.emailSchema.safeParse('test.user@auth0.com').success).toBe(true); + }); + + it('should reject emails not matching the custom regex', () => { + const customRegex = /^[\w.+-]+@auth0\.com$/; + const schema = createInvitationCreateSchema({ + email: { + regex: customRegex, + errorMessage: 'Only @auth0.com emails allowed', + }, + }); + + expect(schema.emailSchema.safeParse('user@example.com').success).toBe(false); + expect(schema.emailSchema.safeParse('user@gmail.com').success).toBe(false); + }); + + it('should use custom error message with custom regex validation failure', () => { + const customRegex = /^[\w.+-]+@auth0\.com$/; + const customErrorMessage = 'Only @auth0.com emails allowed'; + const schema = createInvitationCreateSchema({ + email: { + regex: customRegex, + errorMessage: customErrorMessage, + }, + }); + const result = schema.emailSchema.safeParse('user@example.com'); + + expect(result.success).toBe(false); + if (!result.success && result.error?.errors[0]) { + expect(result.error.errors[0].message).toBe(customErrorMessage); + } + }); + }); + + describe('with custom maxEmails', () => { + it('should override the default maxEmails', () => { + const schema = createInvitationCreateSchema({ maxEmails: 5 }); + expect(schema.maxEmails).toBe(5); + }); + + it('should allow maxEmails of 1', () => { + const schema = createInvitationCreateSchema({ maxEmails: 1 }); + expect(schema.maxEmails).toBe(1); + }); + + it('should default to 10 when maxEmails is not provided', () => { + const schema = createInvitationCreateSchema({}); + expect(schema.maxEmails).toBe(10); + }); + }); + + describe('with empty options', () => { + it('should behave like the default schema', () => { + const schema = createInvitationCreateSchema({}); + + expect(schema.emailSchema.safeParse('user@example.com').success).toBe(true); + expect(schema.emailSchema.safeParse('').success).toBe(false); + expect(schema.maxEmails).toBe(10); + }); + }); + + describe('with undefined options', () => { + it('should behave like the default schema', () => { + const schema = createInvitationCreateSchema(undefined); + + expect(schema.emailSchema.safeParse('user@example.com').success).toBe(true); + expect(schema.emailSchema.safeParse('').success).toBe(false); + expect(schema.maxEmails).toBe(10); + }); + }); + + describe('with no arguments', () => { + it('should return defaults', () => { + const schema = createInvitationCreateSchema(); + + expect(schema.emailSchema.safeParse('user@example.com').success).toBe(true); + expect(schema.emailSchema.safeParse('').success).toBe(false); + expect(schema.maxEmails).toBe(10); + expect(schema.emailErrorMessage).toBe('Please enter a valid email address'); + }); + }); + }); + + describe('return value structure', () => { + it('should return emailSchema, emailRegex, emailErrorMessage, and maxEmails', () => { + const result = createInvitationCreateSchema(); + + expect(result).toHaveProperty('emailSchema'); + expect(result).toHaveProperty('emailRegex'); + expect(result).toHaveProperty('emailErrorMessage'); + expect(result).toHaveProperty('maxEmails'); + }); + + it('should return a Zod schema for emailSchema', () => { + const result = createInvitationCreateSchema(); + expect(result.emailSchema.safeParse).toBeDefined(); + expect(typeof result.emailSchema.safeParse).toBe('function'); + }); + }); + + describe('edge cases', () => { + it('should handle email field config with only regex (no errorMessage)', () => { + const schema = createInvitationCreateSchema({ + email: { regex: /^[\w.+-]+@company\.com$/ }, + }); + + // Should use the default error message since none was provided + expect(schema.emailErrorMessage).toBe('Please enter a valid email address'); + }); + + it('should handle email field config with only errorMessage (no regex)', () => { + const schema = createInvitationCreateSchema({ + email: { errorMessage: 'Custom error' }, + }); + + // Should still use the default regex + expect(schema.emailSchema.safeParse('user@example.com').success).toBe(true); + expect(schema.emailErrorMessage).toBe('Custom error'); + }); + }); +}); diff --git a/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema-types.ts b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema-types.ts new file mode 100644 index 000000000..b115aa96d --- /dev/null +++ b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema-types.ts @@ -0,0 +1,24 @@ +/** + * Invitation creation schema type definitions. + * @module invitation-create-schema-types + * @internal + */ + +/** + * Schema configuration for a single email field. + * @internal + */ +export interface EmailFieldConfig { + regex?: RegExp; + errorMessage?: string; +} + +/** + * Schema configuration for invitation creation form. + * Consumers can override validation rules for each field. + * @internal + */ +export interface InvitationCreateSchemas { + email?: EmailFieldConfig; + maxEmails?: number; +} diff --git a/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema.ts b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema.ts new file mode 100644 index 000000000..557818a4e --- /dev/null +++ b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-create-schema.ts @@ -0,0 +1,77 @@ +/** + * Invitation creation schema for form validation. + * @module invitation-create-schema + * @internal + */ + +import { z } from 'zod'; + +import { type InvitationCreateSchemas } from './invitation-create-schema-types'; + +/** Default email regex pattern. */ +const DEFAULT_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Default maximum number of email addresses allowed. */ +const DEFAULT_MAX_EMAILS = 10; + +/** + * Helper to merge schema field config with defaults. + * @param schema - Schema configuration + * @param field - Form field name + * @param defaultError - Default error message + * @returns The merged field configuration + * @internal + */ +const mergeFieldConfig = ( + schema: InvitationCreateSchemas | undefined, + field: 'email', + defaultError: string, +) => { + const fieldConfig = schema?.[field]; + return fieldConfig + ? { + ...fieldConfig, + errorMessage: fieldConfig.errorMessage || defaultError, + } + : { + errorMessage: defaultError, + }; +}; + +/** + * Creates a schema for invitation create form validation. + * @param options - Schema configuration options (consumers can override) + * @param defaultEmailError - Default error message for invalid email + * @returns Object containing Zod email schema, email regex, and maxEmails + */ +export const createInvitationCreateSchema = ( + options: InvitationCreateSchemas = {}, + defaultEmailError = 'Please enter a valid email address', +) => { + const emailConfig = mergeFieldConfig(options, 'email', defaultEmailError); + + const emailRegex = emailConfig.regex ?? DEFAULT_EMAIL_REGEX; + const emailErrorMessage = emailConfig.errorMessage ?? defaultEmailError; + const maxEmails = options.maxEmails ?? DEFAULT_MAX_EMAILS; + + const emailSchema = z.string().min(1, emailErrorMessage).regex(emailRegex, emailErrorMessage); + + return { + emailSchema, + emailRegex, + emailErrorMessage, + maxEmails, + }; +}; + +/** + * Default invitation create schema configuration. + */ +export const invitationCreateSchemaDefaults = createInvitationCreateSchema(); + +/** + * Type for a validated email value. + */ +export type InternalInvitationEmailValue = z.infer< + typeof invitationCreateSchemaDefaults.emailSchema +>; diff --git a/packages/core/src/schemas/my-organization/member-management/invitations/invitation-schema.ts b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-schema.ts new file mode 100644 index 000000000..c5143b8c3 --- /dev/null +++ b/packages/core/src/schemas/my-organization/member-management/invitations/invitation-schema.ts @@ -0,0 +1,61 @@ +/** + * Invitation validation schemas. + * @module invitation-schema + * @internal + */ + +import { z } from 'zod'; + +/** + * Schema for organization invitation data. + * @internal + */ +export const invitationSchema = z.object({ + id: z.string(), + invitee: z.object({ + email: z.string().email(), + }), + inviter: z.object({ + name: z.string().optional(), + }), + roles: z.array(z.string()).optional(), + created_at: z.string().optional(), + expires_at: z.string().optional(), +}); + +/** + * Schema for invitation list response. + * @internal + */ +export const invitationListResponseSchema = z.object({ + invitations: z.array(invitationSchema), + total: z.number().optional(), + start: z.number().optional(), + limit: z.number().optional(), +}); + +/** + * Schema for creating invitation(s) + * @internal + */ +export const createInvitationSchema = z.object({ + invitees: z.array( + z.object({ + email: z.string().email(), + roles: z.array(z.string()).optional(), + }), + ), +}); + +/** + * Schema for revoking an invitation. + * @internal + */ +export const revokeInvitationSchema = z.object({ + invitation_id: z.string(), +}); + +export type Invitation = z.infer; +export type InvitationListResponse = z.infer; +export type CreateInvitationInput = z.infer; +export type RevokeInvitationInput = z.infer; 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..1c19b950a --- /dev/null +++ b/packages/core/src/services/my-organization/member-management/member-management-types.ts @@ -0,0 +1,99 @@ +/** + * 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; + +/** + * 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/organization-member-management.tsx b/packages/react/src/components/auth0/my-organization/organization-member-management.tsx new file mode 100644 index 000000000..c5bf919cd --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/organization-member-management.tsx @@ -0,0 +1,247 @@ +/** + * Organization member management component. + * @module organization-member-management + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import { Plus } from 'lucide-react'; +import * as React from 'react'; + +import { GateKeeper } from '../shared/gate-keeper/gate-keeper'; + +import { OrganizationInvitationDetailsModal } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal'; +import { OrganizationInvitationRevokeModal } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal'; +import { OrganizationInvitationTable } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table'; +import { OrganizationInvitationCreateModal } from '@/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal'; +import { Header } from '@/components/auth0/shared/header'; +import { StyledScope } from '@/components/auth0/shared/styled-scope'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useOrganizationMemberManagement } from '@/hooks/my-organization/use-organization-member-management'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import type { + OrganizationMemberManagementProps, + UseOrganizationMemberManagementResult, +} from '@/types/my-organization/member-management/organization-member-management-types'; + +/** + * Props for the OrganizationMemberManagementView component. + */ +export interface OrganizationMemberManagementViewProps + extends UseOrganizationMemberManagementResult { + styling: OrganizationMemberManagementProps['styling']; + customMessages: OrganizationMemberManagementProps['customMessages']; + hideHeader: boolean; + readOnly: boolean; +} + +/** + * View component for organization member management. + * @param props - The component props. + * @returns The component. + */ +export function OrganizationMemberManagementView(props: OrganizationMemberManagementViewProps) { + const { + styling, + customMessages, + hideHeader, + readOnly, + activeTab, + invitations, + isFetchingInvitations, + isCreatingInvitation, + isRevokingInvitation, + isResendingInvitation, + invitationPagination, + invitationFilters, + invitationSortConfig, + availableRoles, + availableProviders, + modalState, + setActiveTab, + openModal, + closeModal, + handleCreateSubmit, + handleRevokeConfirm, + handleRevokeResendConfirm, + handleCopyUrl, + handleSortChange, + handleNextPage, + handlePreviousPage, + handlePageSizeChange, + handleRoleFilterChange, + } = props; + + const selectedInvitation = + modalState.type === 'details' || + modalState.type === 'revoke' || + modalState.type === 'revokeResend' + ? modalState.invitation + : null; + + const { isDarkMode } = useTheme(); + const { t } = useTranslator('member_management', customMessages as Record); + + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + return ( + +
    + {!hideHeader && ( +
    +
    openModal({ type: 'create' }), + icon: Plus, + disabled: readOnly, + }, + ] + : [] + } + /> +
    + )} + + setActiveTab(value as 'members' | 'invitations')} + className={currentStyles.classes?.['OrganizationMemberManagement-tabs']} + > + + {t('tabs.members')} + {t('tabs.invitations')} + + + + {/* */} + + + + openModal({ type: 'details', invitation })} + onCopyUrl={handleCopyUrl} + onRevokeAndResend={ + readOnly + ? undefined + : (invitation) => openModal({ type: 'revokeResend', invitation }) + } + onRevoke={ + readOnly ? undefined : (invitation) => openModal({ type: 'revoke', invitation }) + } + onNextPage={handleNextPage} + onPreviousPage={handlePreviousPage} + onPageSizeChange={handlePageSizeChange} + onRoleFilterChange={handleRoleFilterChange} + className={currentStyles.classes?.['OrganizationInvitationTab-table']} + /> + + + + + + invitation && openModal({ type: 'revoke', invitation })} + onResend={(invitation) => invitation && openModal({ type: 'revokeResend', invitation })} + className={currentStyles.classes?.['OrganizationInvitationTab-detailsModal']} + /> + + handleRevokeConfirm()} + className={currentStyles.classes?.['OrganizationInvitationTab-revokeModal']} + /> + + handleRevokeResendConfirm()} + className={currentStyles.classes?.['OrganizationInvitationTab-revokeResendModal']} + /> +
    +
    + ); +} + +/** + * Container component for organization member management. + * @param props - The component props. + * @returns The component. + */ +export function OrganizationMemberManagement(props: OrganizationMemberManagementProps) { + const { + hideHeader = false, + customMessages = {}, + styling = { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + readOnly = false, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + } = props; + + const memberManagement = useOrganizationMemberManagement({ + customMessages, + readOnly, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + }); + + return ( + + + + ); +} diff --git a/packages/react/src/components/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..266f128cc --- /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,386 @@ +import { screen } 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 } from '@/tests/utils'; +import { + createMockDetailsModalProps, + createMockInvitation, + createMockPendingInvitation, + createMockExpiredInvitation, + createMockRoles, + createMockProviders, +} from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks'; + +describe('OrganizationInvitationDetailsModal', () => { + 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: 'https://example.auth0.com/invite?ticket=abc', + }); + + 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('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..16f41d857 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal.tsx @@ -0,0 +1,233 @@ +/** + * 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 { 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/__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..595076fa3 --- /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..b2c55dba6 --- /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 { 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/__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..2757176db --- /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,114 @@ +/** + * 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 ( +
    + + + + {t('invitation.actions.menu_label')} + + + + {/* 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')} + + )} + + {!readOnly && ( + <> + + + + {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/__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..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/__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..7b83b6a4d --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/search-filter/__tests__/search-filter.test.tsx @@ -0,0 +1,103 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, describe, it, expect, afterEach } 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', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + 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(); + }); + }); + + 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('mb-4'); + }); + }); +}); 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/components/auth0/shared/data-pagination.tsx b/packages/react/src/components/auth0/shared/data-pagination.tsx index d41e5e8fb..586fc4244 100644 --- a/packages/react/src/components/auth0/shared/data-pagination.tsx +++ b/packages/react/src/components/auth0/shared/data-pagination.tsx @@ -10,10 +10,8 @@ import { Pagination, PaginationContent, PaginationItem, - PaginationLink, PaginationPrevious, PaginationNext, - PaginationEllipsis, } from '@/components/auth0/shared/pagination'; import { Select, @@ -44,12 +42,12 @@ export const defaultLabels: DataPaginationLabels = { showing: 'Showing', to: 'to', of: 'of', - results: 'results', + results: '', totalResults: 'total results', - show: 'Show', + show: '', perPage: 'per page', - previous: 'Previous', - next: 'Next', + previous: '', + next: '', goToPage: 'Go to page {page}', goToPrevious: 'Go to previous page', goToNext: 'Go to next page', @@ -66,6 +64,7 @@ export interface RegularPaginationState { export interface CheckpointPaginationState { pageSize: number; + currentPage?: number; totalItems?: number; hasNextPage: boolean; hasPreviousPage: boolean; @@ -86,6 +85,14 @@ export interface DataPaginationProps { onPreviousPage?: () => void; } +interface PageRangeInfoProps { + totalItems: number; + currentPage: number; + pageSize: number; + locale?: string; + labels: DataPaginationLabels; +} + const formatNumber = (num: number | undefined | null, locale?: string): string => { if (num === null || num === undefined || isNaN(num)) { return '0'; @@ -96,32 +103,46 @@ const formatNumber = (num: number | undefined | null, locale?: string): string = return num.toLocaleString(resolvedLocale); }; -const interpolate = (str: string, values: Record): string => - str.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? '')); - -const generatePageNumbers = ( +const getPageRange = ( + totalItems: number, currentPage: number, - totalPages: number, - maxVisible: number = 5, -): (number | 'ellipsis')[] => { - if (totalPages <= maxVisible) return Array.from({ length: totalPages }, (_, i) => i + 1); - - const pages: (number | 'ellipsis')[] = [1]; - const range = Math.floor((maxVisible - 3) / 2); - let start = Math.max(2, currentPage - range); - let end = Math.min(totalPages - 1, currentPage + range); - - if (currentPage <= range + 2) end = maxVisible - 2; - if (currentPage >= totalPages - range - 1) start = totalPages - (maxVisible - 2); - - if (start > 2) pages.push('ellipsis'); - for (let i = start; i <= end; i++) pages.push(i); - if (end < totalPages - 1) pages.push('ellipsis'); - pages.push(totalPages); - - return pages; + pageSize: number, + locale?: string, +): { start: string; end: string } => { + const start = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const end = Math.min(currentPage * pageSize, totalItems); + return { + start: formatNumber(start, locale), + end: formatNumber(end, locale), + }; }; +/** + * Renders the page range info. + * + * @param props - Component props. + * @param props.totalItems - Total number of items. + * @param props.currentPage - Current page number. + * @param props.pageSize - Number of items per page. + * @param props.locale - Locale identifier for number formatting. + * @param props.labels - Label text configuration. + * @returns JSX element displaying the page range info. + */ +function PageRangeInfo({ totalItems, currentPage, pageSize, locale, labels }: PageRangeInfoProps) { + const range = getPageRange(totalItems, currentPage, pageSize, locale); + return ( + <> + {labels.showing}{' '} + + {range.start}-{range.end} + {' '} + {labels.of}{' '} + {formatNumber(totalItems, locale)} + {labels.results && <> {labels.results}} + + ); +} + /** * * @param props - Component props. @@ -170,18 +191,6 @@ export function DataPagination({ return Array.from(uniqueOptions).sort((a, b) => a - b); }, [shouldShowPageSizeSelector, pageSizeOptions, currentPageSize]); - const pageNumbers = useMemo( - () => - isRegular && regularState - ? generatePageNumbers( - regularState.currentPage, - regularState.totalPages, - regularState.maxVisiblePages, - ) - : [], - [isRegular, regularState], - ); - useEffect(() => { if (ariaLiveRegionRef.current) { if (isRegular && regularState) { @@ -196,7 +205,6 @@ export function DataPagination({ return null; } - const shouldShowPageNumbers = isRegular; const safeCurrentPageSize = currentPageSize as number; return ( @@ -214,32 +222,22 @@ export function DataPagination({ {showPageInfo && (
    {isRegular && regularState ? ( - <> - {labels.showing}{' '} - - {formatNumber( - regularState.totalItems === 0 - ? 0 - : (regularState.currentPage - 1) * regularState.pageSize + 1, - locale, - )} - {' '} - {labels.to}{' '} - - {formatNumber( - Math.min( - regularState.currentPage * regularState.pageSize, - regularState.totalItems, - ), - locale, - )} - {' '} - {labels.of}{' '} - - {formatNumber(regularState.totalItems, locale)} - {' '} - {labels.results} - + + ) : checkpointState?.totalItems !== undefined && + checkpointState?.currentPage !== undefined ? ( + ) : checkpointState?.totalItems !== undefined ? ( <> @@ -254,7 +252,7 @@ export function DataPagination({
    {shouldShowPageSizeSelector && allPageSizeOptions.length > 0 && (
    - {labels.show} + {labels.show && {labels.show}} +
    + {endAdornment && ( +
    + {endAdornment} +
    + )} +
    + + ); + } + + return ( + +
    + {startAdornment && ( +
    + {startAdornment} +
    + )} + {hasChips && (showAllChips || visibleChips.length > 0) && ( +
    + {visibleChips.map((chip, index) => ( +
    { + chipRefs.current[index] = el; + }} + tabIndex={-1} + onKeyDown={(e) => handleChipKeyDown(e, index)} + onFocus={() => setFocusedChipIndex(index)} + className={cn( + 'focus:ring-ring rounded-lg focus:ring-3 focus:outline-none', + focusedChipIndex === index && 'ring-ring ring-3', + )} + > + handleChipRemove(chip.value)} + className="max-w-xs" + > + {chip.label} + +
    + ))} + {!showAllChips && hiddenChipCount > 0 && ( + + +{hiddenChipCount} more + + )} +
    + )} + + {endAdornment && ( +
    + {endAdornment} +
    + )} +
    +
    + ); +} + +const TextFieldGroupWithRef = React.forwardRef(TextFieldGroup); + +export { TextFieldGroupWithRef as TextFieldGroup, textFieldGroupVariants }; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 646b4e008..f32bcfe11 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -28,3 +28,6 @@ export { useSsoDomainTab } from './my-organization/use-sso-domain-tab'; export { useSsoProviderCreate } from './my-organization/use-sso-provider-create'; export { useSsoProviderEdit } from './my-organization/use-sso-provider-edit'; export { useSsoProviderTable } from './my-organization/use-sso-provider-table'; + +// Member Management hooks +export { useOrganizationMemberManagement } from './my-organization/use-organization-member-management'; diff --git a/packages/react/src/hooks/my-organization/__tests__/use-member-management-service.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-member-management-service.test.ts new file mode 100644 index 000000000..c007cd215 --- /dev/null +++ b/packages/react/src/hooks/my-organization/__tests__/use-member-management-service.test.ts @@ -0,0 +1,413 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { useMemberManagementService } from '@/hooks/my-organization/shared/services/use-member-management-service'; +import { memberManagementQueryKeys } from '@/hooks/my-organization/shared/services/use-member-management-service'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import * as useTranslatorModule from '@/hooks/shared/use-translator'; +import { mockCore, mockToast, createMockI18nService } from '@/tests/utils'; +import { createMockInvitation } from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks'; +import { createTestQueryClientWrapper } from '@/tests/utils/test-provider'; +import type { UseMemberManagementServiceOptions } from '@/types/my-organization/member-management/organization-member-management-types'; + +const { mockedShowToast } = mockToast(); +const { initMockCoreClient } = mockCore(); + +const createDefaultOptions = ( + overrides?: Partial, +): UseMemberManagementServiceOptions => ({ + customMessages: {}, + activeTab: 'invitations', + invitationParams: { + 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', () => { + const options = createDefaultOptions({ activeTab: 'members' }); + const { result } = renderService(options); + + expect(result.current.providersQuery.fetchStatus).toBe('idle'); + }); + }); + + 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', total: 5 }, + }); + + 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', + total: 5, + }); + }); + }); + + 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-management-service.ts b/packages/react/src/hooks/my-organization/shared/services/use-member-management-service.ts new file mode 100644 index 000000000..f3a31f925 --- /dev/null +++ b/packages/react/src/hooks/my-organization/shared/services/use-member-management-service.ts @@ -0,0 +1,201 @@ +/** + * Member management service hook. + * @module use-member-management-service + * @internal + */ + +import { + type MemberInvitation, + type ListIdentityProvidersResponseContent, +} from '@auth0/universal-components-core'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as React from 'react'; + +import { showToast } from '@/components/auth0/shared/toast'; +import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import type { + CreateInvitationInput, + InvitationSortConfig, +} from '@/types/my-organization/member-management/organization-invitation-table-types'; +import type { + UseMemberManagementServiceOptions, + MemberManagementServiceResult, +} from '@/types/my-organization/member-management/organization-member-management-types'; + +export const memberManagementQueryKeys = { + all: ['member-management'] as const, + invitations: () => [...memberManagementQueryKeys.all, 'invitations'] as const, +}; + +const INVITATION_SORT_FIELD_MAP: Record = { + created_at: 'created_at', +}; + +/** + * Builds a sort parameter string for the API. + * @param sortConfig - The sort configuration. + * @returns The formatted sort string, or undefined if no valid sort key. + */ +function buildSortParam(sortConfig: InvitationSortConfig): string | undefined { + if (!sortConfig.key) return undefined; + const apiField = INVITATION_SORT_FIELD_MAP[sortConfig.key]; + if (!apiField) return undefined; + const direction = sortConfig.direction === 'asc' ? '1' : '-1'; + return `${apiField}:${direction}`; +} + +/** + * Service hook for member management API operations. + * @param options - Service configuration options. + * @returns Query and mutation objects for member management. + */ +export function useMemberManagementService( + options: UseMemberManagementServiceOptions, +): MemberManagementServiceResult { + const { + customMessages = {}, + activeTab, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + invitationParams, + } = options; + + const isInvitationsTabActive = activeTab === 'invitations'; + + const { coreClient } = useCoreClient(); + const { t } = useTranslator('member_management', customMessages as Record); + const queryClient = useQueryClient(); + + const providersQuery = useQuery({ + queryKey: [...memberManagementQueryKeys.all, 'identity-providers'], + queryFn: async () => { + const response: ListIdentityProvidersResponseContent = await coreClient! + .getMyOrganizationApiClient() + .organization.identityProviders.list(); + const providers = response.identity_providers ?? []; + return providers.map((p) => ({ + id: p.id!, + name: p.display_name ?? p.name ?? '', + type: p.strategy, + })); + }, + enabled: !!coreClient && isInvitationsTabActive, + }); + + const invitationsQuery = useQuery({ + queryKey: [ + ...memberManagementQueryKeys.invitations(), + invitationParams.pageSize, + invitationParams.fromToken, + invitationParams.filters, + invitationParams.sortConfig, + ], + queryFn: async () => { + const page = await coreClient!.getMyOrganizationApiClient().organization.invitations.list({ + take: invitationParams.pageSize, + from: invitationParams.fromToken, + sort: buildSortParam(invitationParams.sortConfig), + }); + + const invitations: MemberInvitation[] = page.data; + const next = page.response.next ?? null; + const total = (page.response as Record).total as number | undefined; + + return { invitations, next, total }; + }, + enabled: !!coreClient && isInvitationsTabActive, + }); + + const createInvitationMutation = useMutation({ + mutationFn: async (data: CreateInvitationInput) => { + if (createInvitationAction?.onBefore && !createInvitationAction.onBefore(data)) { + throw new Error('Create action cancelled by onBefore'); + } + const response = await coreClient! + .getMyOrganizationApiClient() + .organization.invitations.create({ + invitees: data.invitees, + inviter: data.inviter, + ttl_sec: data.ttl_sec, + }); + return Array.isArray(response) ? response[0] : response; + }, + onSuccess: (result, data) => { + createInvitationAction?.onAfter?.(data, result); + showToast({ type: 'success', message: t('invitation.create.success') }); + queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() }); + }, + onError: () => { + showToast({ type: 'error', message: t('invitation.error.create_failed') }); + }, + }); + + const revokeInvitationMutation = useMutation({ + mutationFn: async (invitation: MemberInvitation) => { + if (revokeInvitationAction?.onBefore && !revokeInvitationAction.onBefore(invitation)) { + throw new Error('Revoke action cancelled by onBefore'); + } + await coreClient! + .getMyOrganizationApiClient() + .organization.invitations.delete(invitation.id!); + return invitation; + }, + onSuccess: (invitation) => { + revokeInvitationAction?.onAfter?.(invitation); + showToast({ type: 'success', message: t('invitation.revoke.success') }); + queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() }); + }, + onError: () => { + showToast({ type: 'error', message: t('invitation.error.revoke_failed') }); + }, + }); + + const resendInvitationMutation = useMutation({ + mutationFn: async (invitation: MemberInvitation) => { + if (resendInvitationAction?.onBefore && !resendInvitationAction.onBefore(invitation)) { + throw new Error('Resend action cancelled by onBefore'); + } + const freshInvitation = await coreClient! + .getMyOrganizationApiClient() + .organization.invitations.get(invitation.id!); + await coreClient! + .getMyOrganizationApiClient() + .organization.invitations.delete(freshInvitation.id ?? invitation.id!); + const email = freshInvitation.invitee?.email ?? invitation.invitee?.email ?? ''; + const roles = freshInvitation.roles ?? invitation.roles; + const response = await coreClient! + .getMyOrganizationApiClient() + .organization.invitations.create({ + invitees: [{ email, roles }], + }); + return Array.isArray(response) ? response[0] : response; + }, + onSuccess: (result, invitation) => { + resendInvitationAction?.onAfter?.(invitation, result); + showToast({ type: 'success', message: t('invitation.success.invitation_resent') }); + queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() }); + }, + onError: () => { + showToast({ type: 'error', message: t('invitation.error.resend_failed') }); + queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() }); + }, + }); + + const fetchInvitationDetails = React.useCallback( + async (invitationId: string): Promise => { + return coreClient!.getMyOrganizationApiClient().organization.invitations.get(invitationId); + }, + [coreClient], + ); + + return { + providersQuery, + invitationsQuery, + createInvitationMutation, + revokeInvitationMutation, + resendInvitationMutation, + fetchInvitationDetails, + }; +} diff --git a/packages/react/src/hooks/my-organization/use-config.ts b/packages/react/src/hooks/my-organization/use-config.ts index f3db4455f..bf0488256 100644 --- a/packages/react/src/hooks/my-organization/use-config.ts +++ b/packages/react/src/hooks/my-organization/use-config.ts @@ -51,6 +51,11 @@ export function useConfig(): UseConfigResult { const isConfigValid = !!allowedStrategies?.length; + const allowedRoles = + ((config as Record)?.allowed_roles as + | Array<{ id: string; name: string; description?: string }> + | undefined) ?? []; + return { config: config ?? null, isLoadingConfig: configQuery.isLoading, @@ -58,5 +63,6 @@ export function useConfig(): UseConfigResult { filteredStrategies, shouldAllowDeletion, isConfigValid, + allowedRoles, }; } diff --git a/packages/react/src/hooks/my-organization/use-organization-member-management.ts b/packages/react/src/hooks/my-organization/use-organization-member-management.ts new file mode 100644 index 000000000..e7c5b232e --- /dev/null +++ b/packages/react/src/hooks/my-organization/use-organization-member-management.ts @@ -0,0 +1,225 @@ +/** + * Organization member management hook. + * @module use-organization-member-management + */ + +import { type MemberInvitation } from '@auth0/universal-components-core'; +import * as React from 'react'; + +import { showToast } from '@/components/auth0/shared/toast'; +import { useMemberManagementService } from '@/hooks/my-organization/shared/services/use-member-management-service'; +import { useConfig } from '@/hooks/my-organization/use-config'; +import { useCheckpointPagination } from '@/hooks/shared/use-checkpoint-pagination'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import type { + CreateInvitationInput, + InvitationFilterState, + InvitationSortConfig, + RoleOption, + IdentityProviderOption, +} from '@/types/my-organization/member-management/organization-invitation-table-types'; +import type { + ActiveTab, + MemberManagementModalState, + UseOrganizationMemberManagementOptions, + UseOrganizationMemberManagementResult, +} from '@/types/my-organization/member-management/organization-member-management-types'; + +export { memberManagementQueryKeys } from '@/hooks/my-organization/shared/services/use-member-management-service'; + +/** + * Hook for organization member management. + * @param options - Hook configuration options. + * @returns State and handler functions. + */ +export function useOrganizationMemberManagement( + options: UseOrganizationMemberManagementOptions, +): UseOrganizationMemberManagementResult { + const { + customMessages = {}, + readOnly = false, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + } = options; + + const { t } = useTranslator('member_management', customMessages as Record); + + const [activeTab, setActiveTab] = React.useState('members'); + + const { allowedRoles } = useConfig(); + const availableRoles: RoleOption[] = allowedRoles; + + const { + pageSize: invitationPageSize, + currentPage: invitationCurrentPage, + fromToken: invitationFromToken, + hasPreviousPage: invitationHasPreviousPage, + sortConfig: invitationSortConfig, + filters: invitationFilters, + goToNextPage: invitationGoToNextPage, + goToPreviousPage: invitationGoToPreviousPage, + changePageSize: invitationChangePageSize, + changeSortConfig: invitationChangeSortConfig, + changeFilters: invitationChangeFilters, + } = useCheckpointPagination(); + + const [modalState, setModalState] = React.useState({ type: null }); + const detailsRequestIdRef = React.useRef(0); + + const { + providersQuery, + invitationsQuery, + createInvitationMutation, + revokeInvitationMutation, + resendInvitationMutation, + fetchInvitationDetails, + } = useMemberManagementService({ + customMessages, + activeTab, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + invitationParams: { + pageSize: invitationPageSize, + fromToken: invitationFromToken, + sortConfig: invitationSortConfig, + filters: invitationFilters, + }, + }); + + const availableProviders: IdentityProviderOption[] = providersQuery.data ?? []; + const currentInvitations = invitationsQuery.data?.invitations ?? []; + const invitationNextToken = invitationsQuery.data?.next ?? null; + const invitationsTotalItems = invitationsQuery.data?.total; + + const openModal = React.useCallback( + async (state: MemberManagementModalState) => { + if (state.type === 'create' && readOnly) return; + if ((state.type === 'revoke' || state.type === 'revokeResend') && readOnly) return; + setModalState(state); + + if (state.type === 'details') { + const requestId = ++detailsRequestIdRef.current; + try { + const response = await fetchInvitationDetails(state.invitation.id!); + if (detailsRequestIdRef.current === requestId) { + setModalState({ type: 'details', invitation: response }); + } + } catch { + if (detailsRequestIdRef.current === requestId) { + showToast({ type: 'error', message: t('invitation.error.fetch_failed') }); + } + } + } + }, + [readOnly, fetchInvitationDetails, t], + ); + + const closeModal = React.useCallback(() => { + setModalState({ type: null }); + }, []); + + const handleCreateSubmit = React.useCallback( + (data: CreateInvitationInput) => { + createInvitationMutation.mutate(data, { + onSuccess: () => closeModal(), + }); + }, + [createInvitationMutation, closeModal], + ); + + const handleRevokeConfirm = React.useCallback(() => { + if (modalState.type !== 'revoke') return; + revokeInvitationMutation.mutate(modalState.invitation, { + onSuccess: () => closeModal(), + }); + }, [modalState, revokeInvitationMutation, closeModal]); + + const handleRevokeResendConfirm = React.useCallback(() => { + if (modalState.type !== 'revokeResend') return; + resendInvitationMutation.mutate(modalState.invitation, { + onSuccess: () => closeModal(), + }); + }, [modalState, resendInvitationMutation, closeModal]); + + const handleCopyUrl = React.useCallback( + async (invitation: MemberInvitation) => { + if (!invitation.invitation_url) return; + try { + await navigator.clipboard.writeText(invitation.invitation_url); + showToast({ type: 'success', message: t('invitation.success.url_copied') }); + } catch { + showToast({ type: 'error', message: t('invitation.error.copy_url_failed') }); + } + }, + [t], + ); + + const handleNextPage = React.useCallback(() => { + if (invitationNextToken) { + invitationGoToNextPage(invitationNextToken); + } + }, [invitationNextToken, invitationGoToNextPage]); + + const handlePreviousPage = React.useCallback(() => { + invitationGoToPreviousPage(); + }, [invitationGoToPreviousPage]); + + const handlePageSizeChange = React.useCallback( + (pageSize: number) => { + invitationChangePageSize(pageSize); + }, + [invitationChangePageSize], + ); + + const handleSortChange = React.useCallback( + (sortConfig: InvitationSortConfig) => { + invitationChangeSortConfig(sortConfig); + }, + [invitationChangeSortConfig], + ); + + const handleRoleFilterChange = React.useCallback( + (roleId: string | undefined) => { + invitationChangeFilters((prev) => ({ ...prev, roleId })); + }, + [invitationChangeFilters], + ); + + return { + activeTab, + isLoading: invitationsQuery.isLoading || invitationsQuery.isFetching, + availableRoles, + availableProviders, + + invitations: currentInvitations, + isFetchingInvitations: invitationsQuery.isLoading || invitationsQuery.isFetching, + isCreatingInvitation: createInvitationMutation.isPending, + isRevokingInvitation: revokeInvitationMutation.isPending, + isResendingInvitation: resendInvitationMutation.isPending, + invitationPagination: { + pageSize: invitationPageSize, + currentPage: invitationCurrentPage, + totalItems: invitationsTotalItems, + hasNextPage: !!invitationNextToken, + hasPreviousPage: invitationHasPreviousPage, + }, + invitationFilters, + invitationSortConfig, + modalState, + + setActiveTab, + openModal, + closeModal, + handleCreateSubmit, + handleRevokeConfirm, + handleRevokeResendConfirm, + handleCopyUrl, + handleNextPage, + handlePreviousPage, + handlePageSizeChange, + handleSortChange, + handleRoleFilterChange, + }; +} diff --git a/packages/react/src/hooks/shared/__tests__/use-checkpoint-pagination.test.ts b/packages/react/src/hooks/shared/__tests__/use-checkpoint-pagination.test.ts new file mode 100644 index 000000000..e38d795e3 --- /dev/null +++ b/packages/react/src/hooks/shared/__tests__/use-checkpoint-pagination.test.ts @@ -0,0 +1,235 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; + +import { useCheckpointPagination } from '@/hooks/shared/use-checkpoint-pagination'; +import type { CheckpointPaginationOptions } from '@/hooks/shared/use-checkpoint-pagination'; + +interface TestFilter { + roleId?: string; + searchQuery?: string; +} + +const renderPagination = (options?: CheckpointPaginationOptions) => + renderHook(() => useCheckpointPagination(options)); + +describe('useCheckpointPagination', () => { + describe('Initial State', () => { + it('should initialize with default values', () => { + const { result } = renderPagination(); + + expect(result.current.pageSize).toBe(10); + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + expect(result.current.hasPreviousPage).toBe(false); + expect(result.current.sortConfig).toEqual({ key: null, direction: 'asc' }); + expect(result.current.filters).toEqual({}); + }); + + it('should initialize with custom defaults', () => { + const { result } = renderPagination({ + defaultPageSize: 25, + defaultSortConfig: { key: 'created_at', direction: 'desc' }, + defaultFilters: { roleId: 'role_admin' }, + }); + + expect(result.current.pageSize).toBe(25); + expect(result.current.sortConfig).toEqual({ key: 'created_at', direction: 'desc' }); + expect(result.current.filters).toEqual({ roleId: 'role_admin' }); + }); + }); + + describe('goToNextPage', () => { + it('should advance to the next page with a token', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + + expect(result.current.currentPage).toBe(2); + expect(result.current.fromToken).toBe('token_page2'); + expect(result.current.hasPreviousPage).toBe(true); + }); + + it('should track multiple page navigations', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.goToNextPage('token_page3'); + }); + + expect(result.current.currentPage).toBe(3); + expect(result.current.fromToken).toBe('token_page3'); + expect(result.current.hasPreviousPage).toBe(true); + }); + }); + + describe('goToPreviousPage', () => { + it('should go back to the previous page', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.goToPreviousPage(); + }); + + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + expect(result.current.hasPreviousPage).toBe(false); + }); + + it('should navigate back through multiple pages correctly', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.goToNextPage('token_page3'); + }); + act(() => { + result.current.goToPreviousPage(); + }); + + expect(result.current.currentPage).toBe(2); + expect(result.current.fromToken).toBe('token_page2'); + expect(result.current.hasPreviousPage).toBe(true); + }); + + it('should not go below page 1', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToPreviousPage(); + }); + + expect(result.current.currentPage).toBe(1); + }); + }); + + describe('changePageSize', () => { + it('should update page size and reset pagination', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.changePageSize(25); + }); + + expect(result.current.pageSize).toBe(25); + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + expect(result.current.hasPreviousPage).toBe(false); + }); + }); + + describe('changeSortConfig', () => { + it('should update sort config and reset pagination', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.changeSortConfig({ key: 'created_at', direction: 'desc' }); + }); + + expect(result.current.sortConfig).toEqual({ key: 'created_at', direction: 'desc' }); + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + expect(result.current.hasPreviousPage).toBe(false); + }); + }); + + describe('changeFilters', () => { + it('should update filters with a direct value and reset pagination', () => { + const { result } = renderPagination(); + + act(() => { + result.current.goToNextPage('token_page2'); + }); + act(() => { + result.current.changeFilters({ roleId: 'role_admin' }); + }); + + expect(result.current.filters).toEqual({ roleId: 'role_admin' }); + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + }); + + it('should update filters with an updater function', () => { + const { result } = renderPagination({ + defaultFilters: { searchQuery: 'test' }, + }); + + act(() => { + result.current.changeFilters((prev) => ({ ...prev, roleId: 'role_member' })); + }); + + expect(result.current.filters).toEqual({ searchQuery: 'test', roleId: 'role_member' }); + }); + }); + + describe('reset', () => { + it('should reset all state to defaults', () => { + const { result } = renderPagination({ + defaultPageSize: 10, + defaultSortConfig: { key: null, direction: 'asc' }, + defaultFilters: {}, + }); + + act(() => { + result.current.goToNextPage('token_page2'); + result.current.changePageSize(50); + }); + act(() => { + result.current.changeSortConfig({ key: 'created_at', direction: 'desc' }); + }); + act(() => { + result.current.changeFilters({ roleId: 'role_admin' }); + }); + act(() => { + result.current.reset(); + }); + + expect(result.current.pageSize).toBe(10); + expect(result.current.currentPage).toBe(1); + expect(result.current.fromToken).toBeUndefined(); + expect(result.current.hasPreviousPage).toBe(false); + expect(result.current.sortConfig).toEqual({ key: null, direction: 'asc' }); + expect(result.current.filters).toEqual({}); + }); + + it('should reset to custom defaults when provided', () => { + const { result } = renderPagination({ + defaultPageSize: 25, + defaultSortConfig: { key: 'created_at', direction: 'desc' }, + defaultFilters: { roleId: 'role_admin' }, + }); + + act(() => { + result.current.changePageSize(50); + }); + act(() => { + result.current.changeSortConfig({ key: null, direction: 'asc' }); + }); + act(() => { + result.current.changeFilters({}); + }); + act(() => { + result.current.reset(); + }); + + expect(result.current.pageSize).toBe(25); + expect(result.current.sortConfig).toEqual({ key: 'created_at', direction: 'desc' }); + expect(result.current.filters).toEqual({ roleId: 'role_admin' }); + }); + }); +}); diff --git a/packages/react/src/hooks/shared/use-checkpoint-pagination.ts b/packages/react/src/hooks/shared/use-checkpoint-pagination.ts new file mode 100644 index 000000000..1d504d4e4 --- /dev/null +++ b/packages/react/src/hooks/shared/use-checkpoint-pagination.ts @@ -0,0 +1,131 @@ +import * as React from 'react'; + +interface SortConfig { + key: string | null; + direction: 'asc' | 'desc'; +} + +export interface CheckpointPaginationOptions { + defaultPageSize?: number; + defaultSortConfig?: SortConfig; + defaultFilters?: TFilter; +} + +export interface UseCheckpointPaginationResult { + pageSize: number; + fromToken: string | undefined; + sortConfig: SortConfig; + filters: TFilter; + currentPage: number; + hasPreviousPage: boolean; + goToNextPage: (nextToken: string) => void; + goToPreviousPage: () => void; + changePageSize: (pageSize: number) => void; + changeSortConfig: (sortConfig: SortConfig) => void; + changeFilters: (updater: TFilter | ((prev: TFilter) => TFilter)) => void; + reset: () => void; +} + +const DEFAULT_PAGE_SIZE = 10; +const DEFAULT_SORT_CONFIG: SortConfig = { key: null, direction: 'asc' }; + +/** + * Hook for checkpoint-based pagination with sort, filter, and auto-reset. + * @param options - Pagination configuration options. + * @returns Pagination state and control functions. + */ +export function useCheckpointPagination>( + options: CheckpointPaginationOptions = {}, +): UseCheckpointPaginationResult { + const { + defaultPageSize = DEFAULT_PAGE_SIZE, + defaultSortConfig = DEFAULT_SORT_CONFIG, + defaultFilters = {} as TFilter, + } = options; + + const [pageSize, setPageSize] = React.useState(defaultPageSize); + const [cursor, setCursor] = React.useState<{ + fromToken: string | undefined; + previousTokens: (string | undefined)[]; + currentPage: number; + }>({ + fromToken: undefined, + previousTokens: [], + currentPage: 1, + }); + const [sortConfig, setSortConfig] = React.useState(defaultSortConfig); + const [filters, setFilters] = React.useState(defaultFilters); + + const resetCursor = React.useCallback(() => { + setCursor({ fromToken: undefined, previousTokens: [], currentPage: 1 }); + }, []); + + const goToNextPage = React.useCallback((nextToken: string) => { + setCursor((prev) => ({ + previousTokens: [...prev.previousTokens, prev.fromToken], + fromToken: nextToken, + currentPage: prev.currentPage + 1, + })); + }, []); + + const goToPreviousPage = React.useCallback(() => { + setCursor((prev) => { + if (prev.previousTokens.length === 0) return prev; + + const newStack = [...prev.previousTokens]; + const prevToken = newStack.pop(); + + return { + previousTokens: newStack, + fromToken: prevToken, + currentPage: Math.max(1, prev.currentPage - 1), + }; + }); + }, []); + + const changePageSize = React.useCallback( + (newPageSize: number) => { + setPageSize(newPageSize); + resetCursor(); + }, + [resetCursor], + ); + + const changeSortConfig = React.useCallback( + (newSortConfig: SortConfig) => { + setSortConfig(newSortConfig); + resetCursor(); + }, + [resetCursor], + ); + + const changeFilters = React.useCallback( + (updater: TFilter | ((prev: TFilter) => TFilter)) => { + setFilters(updater); + resetCursor(); + }, + [resetCursor], + ); + + const reset = React.useCallback(() => { + setPageSize(defaultPageSize); + setSortConfig(defaultSortConfig); + setFilters(defaultFilters); + resetCursor(); + }, [defaultPageSize, defaultSortConfig, defaultFilters, resetCursor]); + + return { + pageSize, + currentPage: cursor.currentPage, + fromToken: cursor.fromToken, + hasPreviousPage: cursor.previousTokens.length > 0, + sortConfig, + filters, + goToNextPage, + goToPreviousPage, + changePageSize, + changeSortConfig, + changeFilters, + reset, + }; +} 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 dd682ce97..2b0f13b39 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,5 +20,6 @@ export const createMockUseConfig = (overrides?: Partial): MockUse }, fetchConfig: vi.fn(async () => undefined), filteredStrategies: [], + allowedRoles: [], ...overrides, }); 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/tests/utils/__mocks__/core/core-client.mocks.ts b/packages/react/src/tests/utils/__mocks__/core/core-client.mocks.ts index 2964c44ac..02aa5053c 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,15 @@ 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), + }, domains: { list: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({}), diff --git a/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts index 102991ff4..9a78bfa91 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,5 +14,6 @@ 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..79ec5d15d --- /dev/null +++ b/packages/react/src/tests/utils/__mocks__/my-organization/member-management/invitation.mocks.ts @@ -0,0 +1,108 @@ +import type { MemberInvitation } from '@auth0/universal-components-core'; +import { vi } from 'vitest'; + +import type { OrganizationInvitationDetailsModalProps } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal'; +import type { OrganizationInvitationRevokeModalProps } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal'; +import type { OrganizationInvitationCreateModalProps } from '@/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal'; +import type { + RoleOption, + IdentityProviderOption, + 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 = (): RoleOption[] => [ + { 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/types/index.ts b/packages/react/src/types/index.ts index e88c10c02..844caff99 100644 --- a/packages/react/src/types/index.ts +++ b/packages/react/src/types/index.ts @@ -25,3 +25,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/config/config-types.ts b/packages/react/src/types/my-organization/config/config-types.ts index b305c8cd9..3b7a27cba 100644 --- a/packages/react/src/types/my-organization/config/config-types.ts +++ b/packages/react/src/types/my-organization/config/config-types.ts @@ -8,6 +8,13 @@ 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; @@ -16,4 +23,5 @@ 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..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 c1dd266d0..31e27964a 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-beta.6 - version: 1.0.0-beta.6 + specifier: ../../auth0-myorganization-js-1.0.0-beta.5.tgz + version: file:auth0-myorganization-js-1.0.0-beta.5.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-beta.6': - resolution: {integrity: sha512-pHqZ86E3+l1++Aw3TbPLRzuCkZYHlQnmdIJz/Ydij8FrGXfUhS3wDP9ZZrwHnPVXBw91pOhxQR5kOavAYRmi5w==} - engines: {node: '>=20.0.0'} + '@auth0/myorganization-js@file:auth0-myorganization-js-1.0.0-beta.5.tgz': + resolution: {integrity: sha512-7KODXVGUP0OTU7l8SUHfhmjtpXgrwETJnysD111i2WxPTb31Ssx4iGMLlkA7wnJwuM+mEjzAdDXVOtaoLAMxbg==, tarball: file:auth0-myorganization-js-1.0.0-beta.5.tgz} + version: 1.0.0-beta.5 + 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-beta.6': + '@auth0/myorganization-js@file:auth0-myorganization-js-1.0.0-beta.5.tgz': dependencies: '@auth0/auth0-auth-js': 1.5.0 @@ -12375,7 +12376,7 @@ snapshots: headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 - path-to-regexp: 6.3.0 + path-to-regexp: 8.4.2 picocolors: 1.1.1 rettime: 0.7.0 strict-event-emitter: 0.5.1