diff --git a/packages/react/src/components/auth0/my-account/__tests__/user-mfa-management.composable.test.tsx b/packages/react/src/components/auth0/my-account/__tests__/user-mfa-management.composable.test.tsx new file mode 100644 index 000000000..565a3a662 --- /dev/null +++ b/packages/react/src/components/auth0/my-account/__tests__/user-mfa-management.composable.test.tsx @@ -0,0 +1,128 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { UserMFAManagement } from '@/components/auth0/my-account/user-mfa-management.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { + createMockAuthenticator, + createMockAuthenticationMethodsResponse, +} from '@/tests/utils/__mocks__/my-account/user-mfa-management/user-mfa-management.mocks'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { UserMFAManagementProps } from '@/types/my-account/user-mfa-management/user-mfa-management-types'; + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const createMockUserMFAManagementProps = ( + overrides?: Partial, +): UserMFAManagementProps => ({ + hideHeader: false, + showActiveOnly: false, + disableEnroll: false, + disableDelete: false, + readOnly: false, + factorConfig: {}, + ...overrides, +}); + +const waitForComponentToLoad = async () => { + await waitFor(() => { + expect(screen.queryByText(/loading\.\.\./i)).not.toBeInTheDocument(); + }); +}; + +const setupEnrolledTotpFactor = ( + apiService: ReturnType['getMyAccountApiClient']>, +) => { + apiService.authenticationMethods.list = vi + .fn() + .mockResolvedValue( + createMockAuthenticationMethodsResponse([ + createMockAuthenticator({ type: 'totp', enrolled: true }), + ]), + ); +}; + +describe('UserMFAManagement — composability', () => { + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockCoreClient = initMockCoreClient(); + setupEnrolledTotpFactor(mockCoreClient.getMyAccountApiClient()); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Tier 1 — default (backwards compatible)', () => { + it('renders the factor cards and enroll buttons when called directly', async () => { + renderWithProviders(); + await waitForComponentToLoad(); + + await waitFor(() => { + expect(screen.getByText('header.title')).toBeInTheDocument(); + }); + + const enrollButtons = screen.getAllByRole('button', { name: /button_text/i }); + expect(enrollButtons.length).toBeGreaterThan(0); + }); + + it('renders the empty state when showActiveOnly and no active factors', async () => { + const apiService = mockCoreClient.getMyAccountApiClient(); + apiService.authenticationMethods.list = vi + .fn() + .mockResolvedValue(createMockAuthenticationMethodsResponse([])); + + renderWithProviders( + , + ); + await waitForComponentToLoad(); + + await screen.findByText(/no_active_mfa/i); + }); + }); + + describe('Tier 3 — structural composition', () => { + it('renders host UI interleaved with a single component instance', async () => { + renderWithProviders( + + +
Host guidance
+ +
, + ); + await waitForComponentToLoad(); + + // Host UI is rendered alongside the component parts. + expect(screen.getByTestId('host-panel')).toBeInTheDocument(); + + // Parts share one model provided by Root: a duplicated model would render a + // second header. The Header part renders the only heading/title; Content + // suppresses its own header (hideHeader). + expect(screen.getAllByRole('heading', { name: 'header.title' })).toHaveLength(1); + expect(screen.getAllByText('header.title')).toHaveLength(1); + + // Content is wired to the shared model and renders the factor cards. + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /button_text/i }).length).toBeGreaterThan(0); + }); + }); + }); + + describe('context safety', () => { + it('throws when a compound part is rendered outside Root', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + /must be rendered inside /, + ); + consoleError.mockRestore(); + }); + }); +}); diff --git a/packages/react/src/components/auth0/my-account/__tests__/user-mfa-management.parity.test.tsx b/packages/react/src/components/auth0/my-account/__tests__/user-mfa-management.parity.test.tsx new file mode 100644 index 000000000..dbfabf430 --- /dev/null +++ b/packages/react/src/components/auth0/my-account/__tests__/user-mfa-management.parity.test.tsx @@ -0,0 +1,172 @@ +import { within, waitFor, screen } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { UserMFAManagement } from '@/components/auth0/my-account/user-mfa-management.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { + createMockAuthenticator, + createMockAuthenticationMethodsResponse, +} from '@/tests/utils/__mocks__/my-account/user-mfa-management/user-mfa-management.mocks'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { UserMFAManagementProps } from '@/types/my-account/user-mfa-management/user-mfa-management-types'; + +/** + * Layout-parity guard. + * + * `UserMFAManagement.Root` + `UserMFAManagement.DefaultLayout` must reproduce + * the Tier-1 (``) anatomy: a single section + * header (honoring `hideHeader`) followed by the per-factor content (one Card + + * enroll button per visible factor type). This test renders both paths with + * identical props and asserts the layout invariants match, so a regression like + * `DefaultLayout` dropping the `hideHeader` gate (a header rendered when it + * should have been suppressed, or vice versa) fails here. + * + * It intentionally compares structural invariants rather than byte-identical + * DOM. MFA has no single top-level action to host-replace (enroll is per-factor, + * see the composable module's HURDLE note), so there is no compound-action path + * to diff; the invariants below are the meaningful layout contract. + * + * Heading count: only the section `Header` emits a `heading` role (an `

`, + * `headingLevel` default 2 after the header a11y fixes). The per-factor + * `CardTitle`s render plain `
`s, not headings, so `heading` count is a + * clean proxy for "is the section header present" with no factor-card noise. + */ + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const createProps = (overrides?: Partial): UserMFAManagementProps => ({ + hideHeader: false, + showActiveOnly: false, + disableEnroll: false, + disableDelete: false, + readOnly: false, + factorConfig: {}, + ...overrides, +}); + +const setupEnrolledTotpFactor = ( + apiService: ReturnType['getMyAccountApiClient']>, +) => { + apiService.authenticationMethods.list = vi + .fn() + .mockResolvedValue( + createMockAuthenticationMethodsResponse([ + createMockAuthenticator({ type: 'totp', enrolled: true }), + ]), + ); +}; + +/** Structural fingerprint of a rendered layout — the invariants that must match. */ +interface LayoutShape { + headings: number; + /** One Card per visible factor type (per-factor content). */ + factorCards: number; + /** One enroll button per visible, editable factor type. */ + enrollButtons: number; + /** Whether the header wrapper keeps its `mb-8` spacing (when a header renders). */ + headerHasSpacing: boolean; +} + +const shapeOf = (container: HTMLElement): LayoutShape => { + const scope = within(container); + // Only the section Header emits a `heading` role; factor CardTitles are divs. + const headings = scope.queryAllByRole('heading'); + return { + headings: headings.length, + factorCards: container.querySelectorAll('[data-slot="card"]').length, + enrollButtons: scope.queryAllByRole('button', { name: /button_text/i }).length, + headerHasSpacing: headings.some((h) => h.closest('.mb-8') != null), + }; +}; + +describe('UserMFAManagement — Tier-1 vs DefaultLayout parity', () => { + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockCoreClient = initMockCoreClient(); + setupEnrolledTotpFactor(mockCoreClient.getMyAccountApiClient()); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + const renderTier1 = (props: UserMFAManagementProps) => { + const { container } = renderWithProviders(); + return container; + }; + + const renderDefaultLayout = (props: UserMFAManagementProps) => { + const { container } = renderWithProviders( + + + , + ); + return container; + }; + + const waitForLoad = async () => + waitFor(() => expect(screen.queryByText(/loading\.\.\./i)).not.toBeInTheDocument()); + + it('matches the Tier-1 anatomy with a header shown (hideHeader: false)', async () => { + const tier1 = renderTier1(createProps({ hideHeader: false })); + const composed = renderDefaultLayout(createProps({ hideHeader: false })); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // Baseline expectations for the Tier-1 path: exactly one section heading, + // per-factor cards + enroll buttons present, header spacing preserved. + expect(tier1Shape.headings).toBe(1); + expect(tier1Shape.headerHasSpacing).toBe(true); + expect(tier1Shape.factorCards).toBeGreaterThan(0); + expect(tier1Shape.enrollButtons).toBeGreaterThan(0); + // Note: cards and enroll buttons are not 1:1 — some visible factor types + // render a card without an enroll action — so we only assert both are + // present here and rely on the full-fingerprint equality below for parity. + + // DefaultLayout must reproduce the full fingerprint exactly. + expect(composedShape).toEqual(tier1Shape); + }); + + it('matches the Tier-1 anatomy with the header gated (hideHeader: true)', async () => { + const tier1 = renderTier1(createProps({ hideHeader: true })); + const composed = renderDefaultLayout(createProps({ hideHeader: true })); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // The section header must be gone in the Tier-1 path, but the factor content + // stays intact. + expect(tier1Shape.headings).toBe(0); + expect(tier1Shape.headerHasSpacing).toBe(false); + expect(tier1Shape.factorCards).toBeGreaterThan(0); + expect(tier1Shape.enrollButtons).toBeGreaterThan(0); + // ...and DefaultLayout must honor hideHeader identically (the regression). + expect(composedShape).toEqual(tier1Shape); + }); + + it('toggles the header purely on hideHeader in DefaultLayout', async () => { + const shown = renderDefaultLayout(createProps({ hideHeader: false })); + const hidden = renderDefaultLayout(createProps({ hideHeader: true })); + await waitForLoad(); + + const shownShape = shapeOf(shown); + const hiddenShape = shapeOf(hidden); + + // Only the header toggles; the per-factor content is unchanged. + expect(shownShape.headings).toBe(1); + expect(hiddenShape.headings).toBe(0); + expect(hiddenShape.factorCards).toBe(shownShape.factorCards); + expect(hiddenShape.enrollButtons).toBe(shownShape.enrollButtons); + }); +}); diff --git a/packages/react/src/components/auth0/my-account/__tests__/user-passkey-management.composable.test.tsx b/packages/react/src/components/auth0/my-account/__tests__/user-passkey-management.composable.test.tsx new file mode 100644 index 000000000..1ba2d1b0e --- /dev/null +++ b/packages/react/src/components/auth0/my-account/__tests__/user-passkey-management.composable.test.tsx @@ -0,0 +1,178 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { UserPasskeyManagement } from '@/components/auth0/my-account/user-passkey-management.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const passkeyList = { + authentication_methods: [ + { id: 'pk-1', type: 'passkey', name: 'My Passkey', created_at: '2024-01-01' }, + ], +}; + +describe('UserPasskeyManagement — composability', () => { + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockCoreClient = initMockCoreClient(); + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: mockCoreClient }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Tier 1 — default (backwards compatible)', () => { + it('renders the empty state and the add control when called directly', async () => { + mockCoreClient.getMyAccountApiClient().authenticationMethods.list = vi + .fn() + .mockResolvedValue({ authentication_methods: [] }); + + renderWithProviders(); + + await screen.findByText('no_passkeys'); + expect(await screen.findByRole('button', { name: 'add_passkey' })).toBeInTheDocument(); + }); + + it('renders the passkey list when the api returns passkeys', async () => { + mockCoreClient.getMyAccountApiClient().authenticationMethods.list = vi + .fn() + .mockResolvedValue(passkeyList); + + renderWithProviders(); + + await screen.findByText('passkey_name'); + expect(screen.getByText('enabled')).toBeInTheDocument(); + }); + }); + + describe('Tier 2 — narrow replacement via render prop', () => { + it('renders the host element and chains its onClick into the add command', async () => { + const user = userEvent.setup(); + const hostOnClick = vi.fn(); + const addAction = { onBefore: vi.fn(() => true), onAfter: vi.fn() }; + + renderWithProviders( + + + + Add key + + } + /> + , + ); + + const hostButton = await screen.findByRole('button', { name: /add key/i }); + expect(hostButton).toBeInTheDocument(); + + await user.click(hostButton); + expect(hostOnClick).toHaveBeenCalledTimes(1); + // The add flow started: the host handler chained into the model command. + await waitFor(() => expect(addAction.onBefore).toHaveBeenCalledTimes(1)); + }); + + it('skips the add command when the host cancels the event', async () => { + const user = userEvent.setup(); + const addAction = { onBefore: vi.fn(() => true), onAfter: vi.fn() }; + + renderWithProviders( + + + e.preventDefault()}> + Add key + + } + /> + , + ); + + await user.click(await screen.findByRole('button', { name: /add key/i })); + expect(addAction.onBefore).not.toHaveBeenCalled(); + }); + }); + + describe('Tier 3 — structural composition', () => { + it('renders host UI interleaved with a single component instance', async () => { + mockCoreClient.getMyAccountApiClient().authenticationMethods.list = vi + .fn() + .mockResolvedValue(passkeyList); + + renderWithProviders( + + +
Host guidance
+ +
, + ); + + await screen.findByText('passkey_name'); + expect(screen.getByTestId('host-panel')).toBeInTheDocument(); + expect(screen.getByText('header.title')).toBeInTheDocument(); + // Parts share one model → the native Add control is rendered exactly once. + expect(screen.getAllByRole('button', { name: 'add_passkey' })).toHaveLength(1); + }); + }); + + describe('AddAction auto-detection', () => { + it('suppresses the in-card Add when a standalone AddAction is mounted', async () => { + mockCoreClient.getMyAccountApiClient().authenticationMethods.list = vi + .fn() + .mockResolvedValue({ authentication_methods: [] }); + + renderWithProviders( + + + + , + ); + + await screen.findByText('no_passkeys'); + // AddAction registers on mount → Content drops its in-card Add, leaving + // exactly one trigger (the standalone one). + await waitFor(() => + expect(screen.getAllByRole('button', { name: 'add_passkey' })).toHaveLength(1), + ); + }); + + it('keeps both Add controls when forceInCardAddButton opts out', async () => { + mockCoreClient.getMyAccountApiClient().authenticationMethods.list = vi + .fn() + .mockResolvedValue({ authentication_methods: [] }); + + renderWithProviders( + + + + , + ); + + await screen.findByText('no_passkeys'); + // Escape hatch: the in-card Add stays, so both triggers render. + await waitFor(() => + expect(screen.getAllByRole('button', { name: 'add_passkey' })).toHaveLength(2), + ); + }); + }); + + describe('context safety', () => { + it('throws when a compound part is rendered outside Root', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + /must be rendered inside /, + ); + consoleError.mockRestore(); + }); + }); +}); diff --git a/packages/react/src/components/auth0/my-account/__tests__/user-passkey-management.parity.test.tsx b/packages/react/src/components/auth0/my-account/__tests__/user-passkey-management.parity.test.tsx new file mode 100644 index 000000000..3e56921cc --- /dev/null +++ b/packages/react/src/components/auth0/my-account/__tests__/user-passkey-management.parity.test.tsx @@ -0,0 +1,149 @@ +import { within, waitFor, screen } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { UserPasskeyManagement } from '@/components/auth0/my-account/user-passkey-management.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { UserPasskeyManagementProps } from '@/types/my-account/user-passkey-management/user-passkey-management-types'; + +/** + * Layout-parity guard. + * + * `UserPasskeyManagement.Root` + `UserPasskeyManagement.DefaultLayout` must + * reproduce the Tier-1 (``) anatomy: a + * section header (honoring `hideHeader`), the passkey content card, and — the + * critical invariant for this component — a SINGLE add-passkey control. + * + * The native Add control lives INSIDE the passkey card (as a `CardAction`), not + * in the header. `DefaultLayout` therefore renders it via `Content` exactly the + * way Tier-1 does. This test renders both paths with identical props and asserts + * the layout invariants match, so a regression that duplicated the add control + * (e.g. a header-hosted Add plus the in-card Add) or drifted on `hideHeader` + * would fail here. + * + * It intentionally compares structural invariants rather than byte-identical + * DOM: the wrapping order and context providers differ between the tiers by + * design, so an innerHTML diff would flag intended differences as failures. + */ + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const createProps = ( + overrides?: Partial, +): UserPasskeyManagementProps => ({ + customMessages: {}, + styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + ...overrides, +}); + +/** Structural fingerprint of a rendered layout — the invariants that must match. */ +interface LayoutShape { + headings: number; + /** Card regions (`data-slot="card"`) — the content region. Empty state = 1. */ + cards: number; + /** The add-passkey trigger count. MUST be 1 (never duplicated). */ + addButtons: number; + /** Whether the header wrapper keeps its `mb-8` spacing (when a header renders). */ + headerHasSpacing: boolean; +} + +const shapeOf = (container: HTMLElement): LayoutShape => { + const scope = within(container); + const headings = scope.queryAllByRole('heading'); + return { + headings: headings.length, + cards: container.querySelectorAll('[data-slot="card"]').length, + // The in-card Add button is labelled with `t('add_passkey')`. + addButtons: scope.queryAllByRole('button', { name: 'add_passkey' }).length, + headerHasSpacing: headings.some((h) => h.closest('.mb-8') != null), + }; +}; + +describe('UserPasskeyManagement — Tier-1 vs DefaultLayout parity', () => { + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockCoreClient = initMockCoreClient(); + // Deterministic empty state: one content card, add control visible, no + // revoke dropdowns — the cleanest anatomy for a structural comparison. + mockCoreClient.getMyAccountApiClient().authenticationMethods.list = vi + .fn() + .mockResolvedValue({ authentication_methods: [] }); + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: mockCoreClient }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + const renderTier1 = (props: UserPasskeyManagementProps) => { + const { container } = renderWithProviders(); + return container; + }; + + const renderDefaultLayout = (props: UserPasskeyManagementProps) => { + const { container } = renderWithProviders( + + + , + ); + return container; + }; + + // Both instances render into the document; wait until both have resolved to + // their empty state (the `no_passkeys` copy is present once per instance). + const waitForBothLoaded = async () => + waitFor(() => expect(screen.getAllByText('no_passkeys')).toHaveLength(2)); + + it('matches the Tier-1 anatomy with a header shown (hideHeader: false)', async () => { + const tier1 = renderTier1(createProps({ hideHeader: false })); + const composed = renderDefaultLayout(createProps({ hideHeader: false })); + await waitForBothLoaded(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // Baseline expectations for the Tier-1 path: one header heading, one content + // card, exactly one add control, header spacing preserved. + expect(tier1Shape).toEqual({ + headings: 1, + cards: 1, + addButtons: 1, + headerHasSpacing: true, + }); + // DefaultLayout must reproduce it exactly — including the SINGLE add control. + expect(composedShape).toEqual(tier1Shape); + }); + + it('matches the Tier-1 anatomy with the header gated (hideHeader: true)', async () => { + const tier1 = renderTier1(createProps({ hideHeader: true })); + const composed = renderDefaultLayout(createProps({ hideHeader: true })); + await waitForBothLoaded(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // The section header must be gone in the Tier-1 path, but the content card + // and its single add control remain. + expect(tier1Shape.headings).toBe(0); + expect(tier1Shape.cards).toBe(1); + expect(tier1Shape.addButtons).toBe(1); + // ...and DefaultLayout must honor hideHeader identically. + expect(composedShape).toEqual(tier1Shape); + }); + + it('toggles the header purely on hideHeader in DefaultLayout', async () => { + const shown = renderDefaultLayout(createProps({ hideHeader: false })); + const hidden = renderDefaultLayout(createProps({ hideHeader: true })); + await waitForBothLoaded(); + + expect(shapeOf(shown).headings).toBe(1); + expect(shapeOf(hidden).headings).toBe(0); + // The add control is unaffected by the header toggle: still exactly one each. + expect(shapeOf(shown).addButtons).toBe(1); + expect(shapeOf(hidden).addButtons).toBe(1); + }); +}); diff --git a/packages/react/src/components/auth0/my-account/user-mfa-management.composable.tsx b/packages/react/src/components/auth0/my-account/user-mfa-management.composable.tsx new file mode 100644 index 000000000..9529f86a7 --- /dev/null +++ b/packages/react/src/components/auth0/my-account/user-mfa-management.composable.tsx @@ -0,0 +1,302 @@ +/** + * Progressive-composability layer for {@link UserMFAManagement}. + * + * Adds compound sub-components (`Root`, `DefaultLayout`, `Header`, `Content`) on + * top of the existing container/view split, with zero breaking changes to the + * Tier-1 default usage (``). + * + * Tiers: + * - Tier 1 (default): `` + * - Tier 3 (structural): compose `Header` / `Content` freely, interleaving host UI. + * - Tier 4 (headless): `useUserMFAModel(options)` — see index re-export. + * + * --- + * ## HURDLE — no uniform host-replaceable action (Tier 2 does not apply) + * + * Unlike single-action table components (e.g. `SsoProviderTable`, whose one + * "Create" button is a natural render-prop slot), MFA's primary action is + * **per-factor**: each visible factor type renders its own "Enroll" button + * *inside its own Card*, deep within {@link UserMFAManagementView}. There is no + * single, top-level action to host-replace, so there is deliberately **no + * `EnrollAction` part and no Tier-2 render-prop layer** here — providing one + * would be dishonest, since it could not map onto the N per-factor buttons. + * + * Hosts that need fully custom per-factor enroll UI use **Tier 4**: the model + * hook (`useUserMFAModel`) exposes `handleEnroll(factorType)`, `visibleFactorTypes`, + * `factorsByType`, and all per-factor enrollment state, so a host can render its + * own factor cards/buttons and drive enrollment directly. This component is the + * clearest case in the library where the uniform action-slot pattern does not fit. + * + * @module user-mfa-management.composable + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import * as React from 'react'; + +import { + UserMFAManagement as UserMFAManagementDefault, + UserMFAManagementView, +} from '@/components/auth0/my-account/user-mfa-management'; +import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper'; +import { Header } from '@/components/auth0/shared/header'; +import { StyledScope } from '@/components/auth0/shared/styled-scope'; +import { useUserMFA } from '@/hooks/my-account/use-user-mfa'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { createComponentContext } from '@/lib/composability'; +import type { + UserMFAManagementProps, + UseUserMFAResult, +} from '@/types/my-account/user-mfa-management/user-mfa-management-types'; + +/** Value shared from `Root` to every compound part. */ +interface UserMFAManagementComposition { + model: UseUserMFAResult; + props: UserMFAManagementProps; +} + +const [UserMFAManagementContext, useUserMFAManagementContext, parts] = + createComponentContext('UserMFAManagement', { + requiredParts: ['Content'], + }); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** + * Stable default for `customMessages`. Hoisted to module scope so an omitted + * prop yields the same reference every render (an inline `{}` would allocate a + * fresh object each render and defeat the composition memo + translator memo). + */ +const EMPTY_CUSTOM_MESSAGES: NonNullable = {}; + +/** + * Stable default for `factorConfig`. Hoisted for the same reason as + * {@link EMPTY_CUSTOM_MESSAGES}: an inline `{}` default would be a fresh object + * each render and defeat the composition memo (and the model hook's memos). + */ +const EMPTY_FACTOR_CONFIG: NonNullable = {}; + +/** Props for {@link Root}. Mirrors {@link UserMFAManagementProps} plus children. */ +export interface UserMFAManagementRootProps extends UserMFAManagementProps { + children?: React.ReactNode; +} + +/** + * Composition boundary. Runs the model hook once and shares it with all + * compound parts, then wraps children in the themed scope + loading gate. + * + * The hook receives exactly the same options the Tier-1 container passes it: + * `disableEnroll` and `schema` are intentionally *not* forwarded to the hook — + * they flow straight to the view via {@link Content}. + * @param props - {@link UserMFAManagementRootProps} + * @returns The provider-wrapped subtree. + */ +function Root({ + children, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, + schema, + hideHeader, + showActiveOnly = false, + disableEnroll, + disableDelete = false, + readOnly = false, + factorConfig = EMPTY_FACTOR_CONFIG, + enrollAction, + deleteAction, +}: UserMFAManagementRootProps) { + useTelemetry('user-mfa-management'); + + const model = useUserMFA({ + showActiveOnly, + readOnly, + disableDelete, + factorConfig, + customMessages, + enrollAction, + deleteAction, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + // Key the composition on concrete prop fields, not the `props` container: a + // rest-spread (`{ ...props }`) allocates a new object every render and would + // defeat this memo. With `model` now memoized in the hook, the context value + // is stable across renders unless a real input changes. + const composition = React.useMemo( + () => ({ + model, + props: { + styling, + customMessages, + schema, + hideHeader, + showActiveOnly, + disableEnroll, + disableDelete, + readOnly, + factorConfig, + enrollAction, + deleteAction, + }, + }), + [ + model, + styling, + customMessages, + schema, + hideHeader, + showActiveOnly, + disableEnroll, + disableDelete, + readOnly, + factorConfig, + enrollAction, + deleteAction, + ], + ); + + return ( + + + + {children} + + + + ); +} + +Root.displayName = 'UserMFAManagement.Root'; + +/** Props for {@link UserMFAManagementHeader}. */ +export interface UserMFAManagementHeaderProps { + className?: string; +} + +/** + * Title/description region. Owns the header copy. MFA has no header-level action + * (enroll is per-factor — see the module HURDLE note), so the header renders + * title + description only. + * @param props - {@link UserMFAManagementHeaderProps} + * @returns The header element. + */ +function UserMFAManagementHeader({ className }: UserMFAManagementHeaderProps) { + const { props } = useUserMFAManagementContext(); + const { t } = useTranslator('user_mfa_management', props.customMessages); + + return ( +
+
+
+ ); +} + +UserMFAManagementHeader.displayName = 'UserMFAManagement.Header'; + +/** + * The factor list body (per-factor cards + enroll/delete buttons + enroll/delete + * modals). Reuses the existing view with its header suppressed (`hideHeader`), + * since the header is owned by the {@link UserMFAManagementHeader} part in + * composition. + * + * Prop pass-through mirrors the Tier-1 container's view render exactly: the model + * supplies all factor/enrollment state and handlers, while the container-level + * props (`schema`, `styling`, `customMessages`, `showActiveOnly`, `disableEnroll`, + * `disableDelete`, `readOnly`, `factorConfig`) come from `Root`. + * @returns The factor list content. + */ +function Content() { + const { model, props } = useUserMFAManagementContext(); + parts.useRegisterPart('Content'); + return ( + + ); +} + +Content.displayName = 'UserMFAManagement.Content'; + +/** + * The default anatomy: header → content. Wrapping in `Root` + `DefaultLayout` + * reproduces the Tier-1 visual output exactly, so hosts can opt into composition + * incrementally. + * @returns The default layout subtree. + */ +function DefaultLayout() { + const { props } = useUserMFAManagementContext(); + return ( + <> + {!props.hideHeader && } + + + ); +} + +DefaultLayout.displayName = 'UserMFAManagement.DefaultLayout'; + +/** + * MFA management with progressive composability. + * + * Callable directly for the Tier-1 default (``), + * and exposes compound parts (`Root`, `DefaultLayout`, `Header`, `Content`) for + * structural (Tier 3) composition. For fully headless (Tier 4) usage, see + * `useUserMFAModel`. + * + * There is intentionally **no Tier-2 render-prop / `EnrollAction` part**: the + * enroll action is per-factor, not a single top-level control (see the module + * HURDLE note). Hosts needing custom per-factor enroll UI use Tier 4. + * + * @example Tier 3 — structural layout with host UI interleaved + * ```tsx + * + * + * + * + * + * ``` + */ +const UserMFAManagement = Object.assign(UserMFAManagementDefault, { + Root, + DefaultLayout, + Header: UserMFAManagementHeader, + Content, +}); + +export { + UserMFAManagement, + Root, + DefaultLayout, + UserMFAManagementHeader as Header, + Content, + useUserMFAManagementContext, +}; diff --git a/packages/react/src/components/auth0/my-account/user-passkey-management.composable.tsx b/packages/react/src/components/auth0/my-account/user-passkey-management.composable.tsx new file mode 100644 index 000000000..e66bce9d8 --- /dev/null +++ b/packages/react/src/components/auth0/my-account/user-passkey-management.composable.tsx @@ -0,0 +1,385 @@ +/** + * Progressive-composability layer for {@link UserPasskeyManagement}. + * + * Adds compound sub-components (`Root`, `DefaultLayout`, `Header`, `AddAction`, + * `Content`) on top of the existing container/view split, with zero breaking + * changes to the Tier-1 default usage (``). + * + * Tiers: + * - Tier 1 (default): `` + * - Tier 2 (narrow): `` + * `} />` ... + * - Tier 3 (structural): compose `Header` / `AddAction` / `Content` freely, interleaving host UI. + * - Tier 4 (headless): `useUserPasskeyModel(options)` — see index re-export. + * + * NOTE: this component has no manual-refresh affordance, so there is no `Refresh` + * part and no `hideRefresh` pass-through (unlike the SsoProviderTable pilot). + * + * HURDLE — the native Add control lives INSIDE the passkey `Card` (as a + * `CardAction`), not in the section header. So `DefaultLayout` keeps the Add + * button where it natively renders (inside `Content`); the standalone + * `AddAction` part exists for Tier-3 custom layouts where the host positions the + * trigger themselves. To avoid two Add controls, a mounted `AddAction` + * auto-registers with `Root`, and `Content` reads that and suppresses its + * in-card Add — so composing `AddAction` alongside `Content` yields exactly one + * trigger, no manual flag required. Hosts that genuinely want both can opt out + * via ``. + * + * @module user-passkey-management.composable + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import * as React from 'react'; + +import { + UserPasskeyManagement as UserPasskeyManagementDefault, + UserPasskeyManagementView, +} from '@/components/auth0/my-account/user-passkey-management'; +import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper'; +import { Header } from '@/components/auth0/shared/header'; +import { StyledScope } from '@/components/auth0/shared/styled-scope'; +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; +import { useUserPasskey } from '@/hooks/my-account/use-user-passkey'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { createComponentContext, mergeRenderProp } from '@/lib/composability'; +import type { + UserPasskeyManagementProps, + UseUserPasskeyResult, +} from '@/types/my-account/user-passkey-management/user-passkey-management-types'; + +/** Value shared from `Root` to every compound part. */ +interface UserPasskeyManagementComposition { + model: UseUserPasskeyResult; + props: UserPasskeyManagementProps; +} + +const [UserPasskeyManagementContext, useUserPasskeyManagementContext, parts] = + createComponentContext('UserPasskeyManagement', { + requiredParts: ['Content'], + }); + +/** + * Stable `register` callback for a mounted {@link AddAction}. Kept in its own + * context (separate from {@link AddActionPresenceContext}) so `AddAction`'s + * registration effect depends only on this stable identity and never re-runs + * when the presence flag flips. + */ +const AddActionRegisterContext = React.createContext<(() => () => void) | null>(null); + +/** + * Whether at least one standalone {@link AddAction} is currently mounted. Read by + * {@link Content} to auto-suppress the view's in-card Add and avoid rendering two + * Add controls in Tier-3 layouts. + */ +const AddActionPresenceContext = React.createContext(false); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** + * Stable default for `customMessages`. Hoisted to module scope so an omitted + * prop yields the same reference every render (an inline `{}` would allocate a + * fresh object each render and defeat the composition memo + translator memo). + */ +const EMPTY_CUSTOM_MESSAGES: NonNullable = {}; + +/** Props for {@link Root}. Mirrors {@link UserPasskeyManagementProps} plus children. */ +export interface UserPasskeyManagementRootProps extends UserPasskeyManagementProps { + children?: React.ReactNode; +} + +/** + * Composition boundary. Runs the model hook once and shares it with all + * compound parts, then wraps children in the themed scope + loading gate. + * @param props - {@link UserPasskeyManagementRootProps} + * @returns The provider-wrapped subtree. + */ +function Root({ + children, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, + hideHeader = false, + schema, + readOnly, + isLoading, + addAction, + revokeAction, + onFetch, +}: UserPasskeyManagementRootProps) { + useTelemetry('user-passkey-management'); + + const model = useUserPasskey({ + customMessages, + addAction, + revokeAction, + onFetch, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + // Track how many standalone AddAction parts are mounted so Content can drop its + // in-card Add when the host has positioned its own trigger. `register` is + // stable (functional setState), so AddAction's effect never re-fires. + const [externalAddCount, setExternalAddCount] = React.useState(0); + const registerAddAction = React.useCallback(() => { + setExternalAddCount((count) => count + 1); + return () => setExternalAddCount((count) => Math.max(0, count - 1)); + }, []); + + // Key the composition on concrete prop fields, not the `props` container: a + // rest-spread (`{ ...props }`) allocates a new object every render and would + // defeat this memo. With `model` now memoized in the hook, the context value + // is stable across renders unless a real input changes. + const composition = React.useMemo( + () => ({ + model, + props: { + styling, + customMessages, + schema, + readOnly, + hideHeader, + isLoading, + addAction, + revokeAction, + onFetch, + }, + }), + [ + model, + styling, + customMessages, + schema, + readOnly, + hideHeader, + isLoading, + addAction, + revokeAction, + onFetch, + ], + ); + + return ( + + + 0}> + + + {children} + + + + + + ); +} + +Root.displayName = 'UserPasskeyManagement.Root'; + +/** Props for {@link AddAction}. */ +export interface UserPasskeyManagementAddActionProps { + /** Host element to render in place of the default UIC button. */ + render?: React.ReactElement; +} + +/** + * The add-passkey trigger. Renders the default UIC button, or a host-supplied + * element via `render`, wired to the model's enroll command. Hidden when the + * host disables adding; disabled while an enrollment is in flight. + * + * Intended for Tier-3 custom layouts where the host positions the trigger. + * `DefaultLayout` does NOT use this part — the native Add control renders inside + * `Content` (see module HURDLE note). + * @param props - {@link UserPasskeyManagementAddActionProps} + * @returns The add action element, or `null` when adding is disabled. + */ +function AddAction({ render }: UserPasskeyManagementAddActionProps) { + const { model, props } = useUserPasskeyManagementContext(); + const { t } = useTranslator('passkey', props.customMessages); + + // Announce this trigger to Root so Content drops its duplicate in-card Add. + // Only register when actually visible (skipped while adding is disabled, where + // the in-card Add is hidden too, so there is nothing to de-duplicate). + const register = React.useContext(AddActionRegisterContext); + React.useEffect(() => { + if (model.disableAdd) return; + return register?.(); + }, [register, model.disableAdd]); + + if (model.disableAdd) { + return null; + } + + const label = t('add_passkey'); + const disabled = model.isEnrolling; + const onClick = () => model.handleAddPasskey(); + + if (render) { + // The host element owns its own label/content; we only wire behavior. + // `mergeRenderProp` returns null for an invalid `render` (Fragment/string/ + // array); fall through to the default button in that case. + const merged = mergeRenderProp(render, { + type: 'button', + disabled, + 'aria-busy': disabled, + onClick, + }); + if (merged) return merged; + } + + return ( + + ); +} + +AddAction.displayName = 'UserPasskeyManagement.AddAction'; + +/** Props for {@link UserPasskeyManagementHeader}. */ +export interface UserPasskeyManagementHeaderProps { + /** Action node rendered in the header's action region. Omitted by default, + * mirroring the original (which has no header actions). */ + action?: React.ReactNode; + className?: string; +} + +/** + * Title/description region. Owns the header copy. The original renders no header + * action, so the action region stays empty unless the host supplies one. + * @param props - {@link UserPasskeyManagementHeaderProps} + * @returns The header element. + */ +function UserPasskeyManagementHeader({ action, className }: UserPasskeyManagementHeaderProps) { + const { props } = useUserPasskeyManagementContext(); + const { t } = useTranslator('passkey', props.customMessages); + + return ( +
+ ); +} + +UserPasskeyManagementHeader.displayName = 'UserPasskeyManagement.Header'; + +/** Props for {@link Content}. */ +export interface UserPasskeyManagementContentProps { + /** + * Escape hatch: force the in-card Add button to render even when a standalone + * {@link AddAction} is also mounted. Rarely needed — by default `Content` + * auto-suppresses its in-card Add whenever an `AddAction` is present, so the + * two do not both render. Set this to keep both (e.g. a host that deliberately + * wants a second trigger). + */ + forceInCardAddButton?: boolean; +} + +/** + * The passkey body (list / empty state + native Add control + revoke modal). + * Reuses the existing view with its header suppressed, since the header is owned + * by the {@link UserPasskeyManagementHeader} part in composition. + * + * Auto-suppresses its in-card Add button when a standalone {@link AddAction} is + * mounted (detected via context), so a Tier-3 layout that positions its own Add + * trigger does not surface two. Pass `forceInCardAddButton` to opt out. + * @param props - {@link UserPasskeyManagementContentProps} + * @returns The passkey content. + */ +function Content({ forceInCardAddButton = false }: UserPasskeyManagementContentProps = {}) { + const { model, props } = useUserPasskeyManagementContext(); + parts.useRegisterPart('Content'); + const hasExternalAddAction = React.useContext(AddActionPresenceContext); + return ( + + ); +} + +Content.displayName = 'UserPasskeyManagement.Content'; + +/** + * The default anatomy: header → content. Wrapping in `Root` + `DefaultLayout` + * reproduces the Tier-1 visual output exactly, so hosts can opt into composition + * incrementally. The Add control renders natively inside `Content`. + * @returns The default layout subtree. + */ +function DefaultLayout() { + const { props } = useUserPasskeyManagementContext(); + return ( + <> + {!props.hideHeader && } + + + ); +} + +DefaultLayout.displayName = 'UserPasskeyManagement.DefaultLayout'; + +/** + * Passkey management with progressive composability. + * + * Callable directly for the Tier-1 default (``), + * and exposes compound parts for narrow (Tier 2) and structural (Tier 3) + * composition. For fully headless (Tier 4) usage, see `useUserPasskeyModel`. + * + * @example Tier 2 — replace the add button + * ```tsx + * + * + * Add key} /> + * + * + * ``` + * + * @example Tier 3 — structural layout with host UI interleaved + * ```tsx + * + * + * + * + * + * ``` + */ +const UserPasskeyManagement = Object.assign(UserPasskeyManagementDefault, { + Root, + DefaultLayout, + Header: UserPasskeyManagementHeader, + AddAction, + Content, +}); + +export { + UserPasskeyManagement, + Root, + DefaultLayout, + UserPasskeyManagementHeader as Header, + AddAction, + Content, + useUserPasskeyManagementContext, +}; diff --git a/packages/react/src/components/auth0/my-account/user-passkey-management.tsx b/packages/react/src/components/auth0/my-account/user-passkey-management.tsx index cb0b1d043..557d24969 100644 --- a/packages/react/src/components/auth0/my-account/user-passkey-management.tsx +++ b/packages/react/src/components/auth0/my-account/user-passkey-management.tsx @@ -116,6 +116,7 @@ function UserPasskeyManagementView(props: UserPasskeyManagementViewProps) { customMessages, hideHeader, disableAdd, + hideAddButton = false, disableRevoke, isRevokeModalOpen, currentPasskey, @@ -151,7 +152,7 @@ function UserPasskeyManagementView(props: UserPasskeyManagementViewProps) { )} {!hasPasskeys && {t('no_passkeys')}} - {!disableAdd && ( + {!disableAdd && !hideAddButton && ( + } + /> + + , + ); + await waitForComponentToLoad(); + + const hostButton = screen.getByRole('button', { name: /add domain/i }); + expect(hostButton).toBeInTheDocument(); + + await user.click(hostButton); + expect(hostOnClick).toHaveBeenCalledTimes(1); + // handleCreateClick opens the create modal. + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + }); + + it('skips the create command when the host cancels the event', async () => { + const user = userEvent.setup(); + + renderWithProviders( + + + e.preventDefault()}> + Add domain + + } + /> + + , + ); + await waitForComponentToLoad(); + + await user.click(screen.getByRole('button', { name: /add domain/i })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + describe('Tier 3 — structural composition', () => { + it('omits the refresh region and renders host UI interleaved', async () => { + renderWithProviders( + + +
Host guidance
+ +
, + ); + await waitForComponentToLoad(); + + expect(screen.getByTestId('host-panel')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + // No Refresh part rendered => no refresh control present. + expect(screen.queryByRole('button', { name: /refresh/i })).not.toBeInTheDocument(); + }); + + it('renders a single table (parts share one model, no duplication)', async () => { + renderWithProviders( + + + + + , + ); + await waitForComponentToLoad(); + + expect(screen.getAllByRole('table')).toHaveLength(1); + await waitFor(() => expect(screen.getAllByText(mockDomain.domain).length).toBeGreaterThan(0)); + }); + }); + + describe('context safety', () => { + it('throws when a compound part is rendered outside Root', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + /must be rendered inside /, + ); + consoleError.mockRestore(); + }); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/domain-table.parity.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/domain-table.parity.test.tsx new file mode 100644 index 000000000..97ebbc673 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/domain-table.parity.test.tsx @@ -0,0 +1,148 @@ +import { within, waitFor, screen } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { DomainTable } from '@/components/auth0/my-organization/domain-table.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { + createMockDomain, + createMockVerifiedDomain, + createMockDomainTableProps, +} from '@/tests/utils/__mocks__/my-organization/domain-management/domain.mocks'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { DomainTableProps } from '@/types/my-organization/domain-management/domain-table-types'; + +/** + * Layout-parity guard. + * + * `DomainTable.Root` + `DomainTable.DefaultLayout` must reproduce the Tier-1 + * (``) anatomy: header (honoring `hideHeader`), + * refresh region, single table, and create action. This test renders both + * paths with identical props and asserts the layout invariants match, so + * regressions like the historical `hideHeader` drift in `DefaultLayout` (a + * header rendered when it should have been gated) fail here. + * + * It intentionally compares structural invariants rather than byte-identical + * DOM: the compound header action (`CreateAction` + `PermissionDeniedTooltip`) + * and the Tier-1 `Header actions={[...]}` path build the trigger differently by + * design, so an innerHTML diff would flag intended differences as failures. + */ + +mockToast(); +const { initMockCoreClient } = mockCore(); + +/** Structural fingerprint of a rendered layout — the invariants that must match. */ +interface LayoutShape { + headings: number; + tables: number; + refreshControls: number; + createButtons: number; + /** Whether the header wrapper keeps its `mb-8` spacing (when a header renders). */ + headerHasSpacing: boolean; +} + +const shapeOf = (container: HTMLElement): LayoutShape => { + const scope = within(container); + const headings = scope.queryAllByRole('heading'); + return { + headings: headings.length, + tables: scope.queryAllByRole('table').length, + // Count the refresh control by its button rather than `role="status"`: + // DataPagination also emits `role="status"`, so a bare status count would + // conflate the movable Refresh part with the pagination live region. + refreshControls: scope.queryAllByRole('button', { name: /refresh/i }).length, + createButtons: scope.queryAllByRole('button', { name: /create/i }).length, + // The shared Header wrapper carries `mb-8` spacing when it renders. + headerHasSpacing: headings.some((h) => h.closest('.mb-8') != null), + }; +}; + +describe('DomainTable — Tier-1 vs DefaultLayout parity', () => { + const mockDomain = createMockDomain(); + const mockVerifiedDomain = createMockVerifiedDomain(); + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCoreClient = initMockCoreClient(); + + const apiService = mockCoreClient.getMyOrganizationApiClient(); + (apiService.organization.domains.list as ReturnType).mockResolvedValue({ + response: { organization_domains: [mockDomain, mockVerifiedDomain] }, + }); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + const renderTier1 = (props: DomainTableProps) => { + const { container } = renderWithProviders(); + return container; + }; + + const renderDefaultLayout = (props: DomainTableProps) => { + const { container } = renderWithProviders( + + + , + ); + return container; + }; + + const waitForLoad = async () => + waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument()); + + it('matches the Tier-1 anatomy with a header shown (hideHeader: false)', async () => { + const tier1 = renderTier1(createMockDomainTableProps({ hideHeader: false })); + const composed = renderDefaultLayout(createMockDomainTableProps({ hideHeader: false })); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // Baseline expectations for the Tier-1 path: one header heading, one table, + // one refresh control, one create action, spacing preserved. + expect(tier1Shape).toEqual({ + headings: 1, + tables: 1, + refreshControls: 1, + createButtons: 1, + headerHasSpacing: true, + }); + // DefaultLayout must reproduce it exactly. + expect(composedShape).toEqual(tier1Shape); + }); + + it('matches the Tier-1 anatomy with the header gated (hideHeader: true)', async () => { + const tier1 = renderTier1(createMockDomainTableProps({ hideHeader: true })); + const composed = renderDefaultLayout(createMockDomainTableProps({ hideHeader: true })); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // The header (and its create action) must be gone in the Tier-1 path... + expect(tier1Shape.headings).toBe(0); + expect(tier1Shape.createButtons).toBe(0); + expect(tier1Shape.tables).toBe(1); + // ...while the movable refresh control still renders. + expect(tier1Shape.refreshControls).toBe(1); + // ...and DefaultLayout must honor hideHeader identically (the regression). + expect(composedShape).toEqual(tier1Shape); + }); + + it('toggles the header purely on hideHeader in DefaultLayout', async () => { + const shown = renderDefaultLayout(createMockDomainTableProps({ hideHeader: false })); + const hidden = renderDefaultLayout(createMockDomainTableProps({ hideHeader: true })); + await waitForLoad(); + + expect(shapeOf(shown).headings).toBe(1); + expect(shapeOf(hidden).headings).toBe(0); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/organization-details-edit.composable.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/organization-details-edit.composable.test.tsx new file mode 100644 index 000000000..13790cdf2 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/organization-details-edit.composable.test.tsx @@ -0,0 +1,110 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { OrganizationDetailsEdit } from '@/components/auth0/my-organization/organization-details-edit.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { createMockOrganization } from '@/tests/utils/__mocks__/my-organization/organization-management/organization-details.mocks'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { OrganizationDetailsEditProps } from '@/types/my-organization/organization-management/organization-details-edit-types'; + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const createMockOrganizationDetailsEditProps = ( + overrides?: Partial, +): OrganizationDetailsEditProps => ({ + schema: undefined, + customMessages: {}, + styling: { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, + }, + readOnly: false, + hideHeader: false, + saveAction: undefined, + cancelAction: undefined, + backButton: undefined, + ...overrides, +}); + +const waitForComponentToLoad = async () => { + return await screen.findByDisplayValue('Auth0 Corporation'); +}; + +describe('OrganizationDetailsEdit — composability', () => { + const mockOrganization = createMockOrganization(); + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCoreClient = initMockCoreClient(); + + const apiService = mockCoreClient.getMyOrganizationApiClient(); + (apiService.organizationDetails.get as ReturnType).mockResolvedValue( + mockOrganization, + ); + (apiService.organizationDetails.update as ReturnType).mockResolvedValue( + mockOrganization, + ); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Tier 1 — default (backwards compatible)', () => { + it('renders the form with submit and cancel actions when called directly', async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + const displayNameInput = await waitForComponentToLoad(); + expect(displayNameInput).toHaveValue('Auth0 Corporation'); + + expect(screen.getByRole('button', { name: /submit_button_label/i })).toBeInTheDocument(); + + // The cancel action surfaces once the form is dirty. + await user.clear(displayNameInput); + await user.type(displayNameInput, 'Modified Corporation'); + expect(screen.getByRole('button', { name: /cancel_button_label/i })).toBeInTheDocument(); + }); + }); + + describe('Tier 3 — structural composition', () => { + it('renders host UI interleaved with a single form instance', async () => { + renderWithProviders( + + +
Host guidance
+ +
, + ); + + await waitForComponentToLoad(); + + expect(screen.getByTestId('host-panel')).toBeInTheDocument(); + // A duplicated model would render a second form/submit button. Parts read + // the single model provided by Root via context. + expect(screen.getAllByRole('button', { name: /submit_button_label/i })).toHaveLength(1); + }); + }); + + describe('context safety', () => { + it('throws when a compound part is rendered outside Root', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + /must be rendered inside /, + ); + consoleError.mockRestore(); + }); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/organization-details-edit.parity.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/organization-details-edit.parity.test.tsx new file mode 100644 index 000000000..9667a907e --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/organization-details-edit.parity.test.tsx @@ -0,0 +1,202 @@ +import { within, waitFor, screen } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { OrganizationDetailsEdit } from '@/components/auth0/my-organization/organization-details-edit.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { createMockOrganization } from '@/tests/utils/__mocks__/my-organization/organization-management/organization-details.mocks'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { OrganizationDetailsEditProps } from '@/types/my-organization/organization-management/organization-details-edit-types'; + +/** + * Layout-parity guard for the `organization-details-edit` composable. + * + * `OrganizationDetailsEdit.Root` + `OrganizationDetailsEdit.DefaultLayout` must + * reproduce the Tier-1 (``) anatomy: a + * header block (title + optional back button) and a single edit form with its + * fields and the Save (submit) / Cancel form actions. Both paths are rendered + * with identical props and their structural invariants are compared. + * + * PRIMARY GUARD — header `mb-8` spacing (checklist #3). The composable `Header` + * part wraps its `Header` in `cn('mb-8', className)` to mirror the Tier-1 view's + * `
` header wrapper (base view `:114-115`). This test pins + * that the header title heading sits inside an `mb-8` spacing wrapper in BOTH + * paths, using the same `heading.closest('.mb-8')` technique as the + * SsoProviderTable pilot — but targeted at the header heading specifically + * (`header.title`) rather than any heading, since the form body also renders + * section headings inside their own `mb-8` content wrapper. + * + * It compares structural invariants, not byte-identical DOM: Tier-1 renders the + * header inside the view's `w-full` wrapper while the composable renders it as a + * standalone `Header` part, so an innerHTML diff would flag intended, cosmetic + * differences as failures. + */ + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const createProps = ( + overrides?: Partial, +): OrganizationDetailsEditProps => ({ + schema: undefined, + customMessages: {}, + styling: { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, + }, + readOnly: false, + hideHeader: false, + saveAction: undefined, + cancelAction: undefined, + backButton: undefined, + ...overrides, +}); + +/** Structural fingerprint of a rendered layout — the invariants that must match. */ +interface LayoutShape { + /** Header title headings (`header.title`) — the composable/view `Header` block. */ + headerHeadings: number; + /** Editable form fields (display name, name, etc.). */ + textboxes: number; + /** Number of `
` regions — exactly one edit form in the default anatomy. */ + forms: number; + submitButtons: number; + /** Whether every rendered header heading sits inside an `mb-8` wrapper (checklist #3). */ + headerHasSpacing: boolean; +} + +const shapeOf = (container: HTMLElement): LayoutShape => { + const scope = within(container); + // The header title heading is the `Header` part's `

` (`t('header.title')`); + // form-body section headings (`sections.*.title`) render as separate `

`s. + const headerHeadings = scope.queryAllByRole('heading', { name: /header\.title/i }); + return { + headerHeadings: headerHeadings.length, + textboxes: scope.queryAllByRole('textbox').length, + forms: container.querySelectorAll('form').length, + submitButtons: scope.queryAllByRole('button', { name: /submit_button_label/i }).length, + headerHasSpacing: + headerHeadings.length > 0 && headerHeadings.every((h) => h.closest('.mb-8') != null), + }; +}; + +/** The header-independent invariants — identical across paths regardless of `hideHeader`. */ +const formShapeOf = (shape: LayoutShape) => ({ + textboxes: shape.textboxes, + forms: shape.forms, + submitButtons: shape.submitButtons, +}); + +describe('OrganizationDetailsEdit — Tier-1 vs DefaultLayout parity', () => { + const mockOrganization = createMockOrganization(); + + beforeEach(() => { + vi.clearAllMocks(); + + const mockCoreClient = initMockCoreClient(); + const apiService = mockCoreClient.getMyOrganizationApiClient(); + (apiService.organizationDetails.get as ReturnType).mockResolvedValue( + mockOrganization, + ); + (apiService.organizationDetails.update as ReturnType).mockResolvedValue( + mockOrganization, + ); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + const renderTier1 = (props: OrganizationDetailsEditProps) => { + const { container } = renderWithProviders(); + return container; + }; + + const renderDefaultLayout = (props: OrganizationDetailsEditProps) => { + const { container } = renderWithProviders( + + + , + ); + return container; + }; + + // Both paths render past the GateKeeper spinner once the org loads; the display + // name input then carries the mocked value. Rendering both means two inputs. + const waitForBothLoaded = async () => + waitFor(() => + expect(screen.getAllByDisplayValue('Auth0 Corporation').length).toBeGreaterThanOrEqual(2), + ); + + it('reproduces the Tier-1 anatomy in DefaultLayout with the header shown (hideHeader: false)', async () => { + const tier1 = renderTier1(createProps({ hideHeader: false })); + const composed = renderDefaultLayout(createProps({ hideHeader: false })); + await waitForBothLoaded(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // Tier-1 baseline: one header title heading with `mb-8` spacing, a single form + // with its fields and the Save (submit) action. + expect(tier1Shape.headerHeadings).toBe(1); + expect(tier1Shape.forms).toBe(1); + expect(tier1Shape.submitButtons).toBe(1); + expect(tier1Shape.textboxes).toBeGreaterThanOrEqual(1); + expect(tier1Shape.headerHasSpacing).toBe(true); + + // DefaultLayout must reproduce the Tier-1 fingerprint exactly — INCLUDING the + // header `mb-8` spacing (checklist #3, the historical regression this guards). + expect(composedShape).toEqual(tier1Shape); + expect(composedShape.headerHasSpacing).toBe(true); + }); + + it('honors hideHeader in the Tier-1 path (header gated, form unchanged)', async () => { + const shown = renderTier1(createProps({ hideHeader: false })); + const hidden = renderTier1(createProps({ hideHeader: true })); + await waitForBothLoaded(); + + const shownShape = shapeOf(shown); + const hiddenShape = shapeOf(hidden); + + // Tier-1 gates the header on `hideHeader` (base view `:114`)... + expect(shownShape.headerHeadings).toBe(1); + expect(hiddenShape.headerHeadings).toBe(0); + // ...while the form region (fields + submit) is identical either way. + expect(formShapeOf(hiddenShape)).toEqual(formShapeOf(shownShape)); + }); + + it('matches the Tier-1 anatomy with the header gated (hideHeader: true)', async () => { + const tier1 = renderTier1(createProps({ hideHeader: true })); + const composed = renderDefaultLayout(createProps({ hideHeader: true })); + await waitForBothLoaded(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // Tier-1 gates the header; form region stays intact. + expect(tier1Shape.headerHeadings).toBe(0); + expect(tier1Shape.headerHasSpacing).toBe(false); + expect(tier1Shape.forms).toBe(1); + expect(tier1Shape.submitButtons).toBe(1); + + // DefaultLayout must honor hideHeader identically (fixed: previously rendered + // the header unconditionally, ignoring hideHeader). + expect(composedShape).toEqual(tier1Shape); + }); + + it('toggles the header purely on hideHeader in DefaultLayout', async () => { + const shown = renderDefaultLayout(createProps({ hideHeader: false })); + const hidden = renderDefaultLayout(createProps({ hideHeader: true })); + await waitForBothLoaded(); + + expect(shapeOf(shown).headerHeadings).toBe(1); + expect(shapeOf(shown).headerHasSpacing).toBe(true); + expect(shapeOf(hidden).headerHeadings).toBe(0); + // Form region is identical either way. + expect(formShapeOf(shapeOf(shown))).toEqual(formShapeOf(shapeOf(hidden))); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/organization-member-detail.composable.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-detail.composable.test.tsx new file mode 100644 index 000000000..6effc1e99 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-detail.composable.test.tsx @@ -0,0 +1,103 @@ +import { screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { OrganizationMemberDetail } from '@/components/auth0/my-organization/organization-member-detail.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { + createMockMember, + createMockAvailableRoles, + createMockOrganizationMemberDetailProps, +} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const waitForComponentToLoad = async () => { + return await screen.findByText('member.detail.back_button'); +}; + +describe('OrganizationMemberDetail — composability', () => { + const mockMember = createMockMember(); + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCoreClient = initMockCoreClient(); + + const apiService = mockCoreClient.getMyOrganizationApiClient(); + (apiService.organization.members.get as ReturnType).mockResolvedValue(mockMember); + (apiService.organization.configuration.get as ReturnType).mockResolvedValue({ + allowed_strategies: ['samlp', 'oidc'], + connection_deletion_behavior: 'allow', + allowed_roles: createMockAvailableRoles(), + }); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Tier 1 — default (backwards compatible)', () => { + it('renders the member detail (name/header appears after load) when called directly', async () => { + renderWithProviders( + , + ); + + await waitForComponentToLoad(); + + expect(screen.getByRole('heading', { name: mockMember.name! })).toBeInTheDocument(); + }); + }); + + describe('Tier 3 — structural composition', () => { + it('renders host panel plus a single detail instance sharing one model', async () => { + renderWithProviders( + + +
Host guidance
+ +
, + ); + + await waitForComponentToLoad(); + + expect(screen.getByTestId('host-panel')).toBeInTheDocument(); + // A duplicated model (or a header leaking from Content) would render a + // second header/back button. The single Header part reads the one model + // provided by Root via context; Content suppresses the view's own header. + expect(screen.getAllByText('member.detail.back_button')).toHaveLength(1); + expect(screen.getByRole('heading', { name: mockMember.name! })).toBeInTheDocument(); + }); + + it('suppresses the built-in header when Content is composed without Header', async () => { + renderWithProviders( + + + , + ); + + // Tabs render (Content is present); the avatar/back header does not. + await screen.findByText('member.detail.tabs.details'); + expect(screen.queryByText('member.detail.back_button')).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: mockMember.name! })).not.toBeInTheDocument(); + }); + }); + + describe('context safety', () => { + it('throws when Content is rendered outside Root', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + /must be rendered inside /, + ); + consoleError.mockRestore(); + }); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/organization-member-detail.parity.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-detail.parity.test.tsx new file mode 100644 index 000000000..48d4bb773 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-detail.parity.test.tsx @@ -0,0 +1,174 @@ +import { within, waitFor, screen } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { OrganizationMemberDetail } from '@/components/auth0/my-organization/organization-member-detail.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { + createMockMember, + createMockAvailableRoles, + createMockOrganizationMemberDetailProps, +} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { OrganizationMemberDetailProps } from '@/types/my-organization/member-management/organization-member-detail-types'; + +/** + * Layout-parity guard. + * + * `OrganizationMemberDetail.Root` + `OrganizationMemberDetail.DefaultLayout` must + * reproduce the Tier-1 (``) anatomy: the + * avatar/back-button header (honoring `hideHeader`) followed by the tabs content + * body. This test renders both paths with identical props and asserts the layout + * invariants match, so a regression like `hideHeader` drift in `DefaultLayout` + * (a header rendered when it should have been gated, or vice versa) fails here. + * + * It intentionally compares structural invariants rather than byte-identical DOM. + * + * Anatomy adaptation vs the SsoProviderTable template: this component has no + * shared title/description header, no refresh region, and no header create + * action (see the composable module's HURDLE note). Its header is the + * data-driven avatar/back header, so the header signal is keyed on the back + * button (`role="button"`, name `member.detail.back_button` — unique to the + * header) plus the member-name `h1`. The content region is the Radix tabs + * (one `tablist`, two `tab`s). The content also contributes one heading of its + * own, so a header-shown layout carries two headings total. + * + * The Tier-1 container destructures and forwards `hideHeader` to the view, so + * both paths honor it identically. + */ + +mockToast(); +const { initMockCoreClient } = mockCore(); + +/** Structural fingerprint of a rendered layout — the invariants that must match. */ +interface LayoutShape { + /** Total headings — the header contributes the member-name `h1`. */ + headings: number; + /** Header signal: the avatar/back header's back button (unique to the header). */ + backButtons: number; + /** Header signal: heading named after the member. */ + memberHeadings: number; + /** Content region: Radix `TabsList`. */ + tabLists: number; + /** Content region: Radix `TabsTrigger`s (details + roles). */ + tabs: number; +} + +const mockMember = createMockMember(); + +const shapeOf = (container: HTMLElement): LayoutShape => { + const scope = within(container); + return { + headings: scope.queryAllByRole('heading').length, + backButtons: scope.queryAllByRole('button', { name: /member\.detail\.back_button/i }).length, + memberHeadings: scope.queryAllByRole('heading', { name: mockMember.name! }).length, + tabLists: scope.queryAllByRole('tablist').length, + tabs: scope.queryAllByRole('tab').length, + }; +}; + +describe('OrganizationMemberDetail — Tier-1 vs DefaultLayout parity', () => { + beforeEach(() => { + vi.clearAllMocks(); + + const mockCoreClient = initMockCoreClient(); + const apiService = mockCoreClient.getMyOrganizationApiClient(); + (apiService.organization.members.get as ReturnType).mockResolvedValue(mockMember); + (apiService.organization.configuration.get as ReturnType).mockResolvedValue({ + allowed_strategies: ['samlp', 'oidc'], + connection_deletion_behavior: 'allow', + allowed_roles: createMockAvailableRoles(), + }); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: mockCoreClient }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + const renderTier1 = (props: OrganizationMemberDetailProps) => { + const { container } = renderWithProviders(); + return container; + }; + + const renderDefaultLayout = (props: OrganizationMemberDetailProps) => { + const { container } = renderWithProviders( + + + , + ); + return container; + }; + + // The tabs body renders regardless of `hideHeader`, so it is the stable + // load signal for both the header-shown and header-gated scenarios. + const waitForLoad = async () => + waitFor(() => + expect(screen.queryAllByText('member.detail.tabs.details').length).toBeGreaterThan(0), + ); + + it('reproduces the Tier-1 anatomy exactly when the header is shown (hideHeader: false)', async () => { + const tier1 = renderTier1(createMockOrganizationMemberDetailProps({ hideHeader: false })); + const composed = renderDefaultLayout( + createMockOrganizationMemberDetailProps({ hideHeader: false }), + ); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // Baseline expectations for the Tier-1 path: header present (one back button, + // one member-name heading) plus the content region (one tablist, two tabs). + // Total headings is two: the header's member-name h1 plus the content's own. + expect(tier1Shape).toEqual({ + headings: 2, + backButtons: 1, + memberHeadings: 1, + tabLists: 1, + tabs: 2, + }); + // DefaultLayout must reproduce the Tier-1 anatomy exactly. + expect(composedShape).toEqual(tier1Shape); + }); + + it('matches the Tier-1 anatomy with the header gated (hideHeader: true)', async () => { + const tier1 = renderTier1(createMockOrganizationMemberDetailProps({ hideHeader: true })); + const composed = renderDefaultLayout( + createMockOrganizationMemberDetailProps({ hideHeader: true }), + ); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // Both paths gate the header: no back button, no member-name heading. + expect(tier1Shape.backButtons).toBe(0); + expect(tier1Shape.memberHeadings).toBe(0); + expect(tier1Shape.tabLists).toBe(1); + expect(tier1Shape.tabs).toBe(2); + + // DefaultLayout must reproduce the Tier-1 fingerprint exactly. + expect(composedShape).toEqual(tier1Shape); + }); + + it('toggles the header purely on hideHeader in DefaultLayout', async () => { + const shown = renderDefaultLayout( + createMockOrganizationMemberDetailProps({ hideHeader: false }), + ); + const hidden = renderDefaultLayout( + createMockOrganizationMemberDetailProps({ hideHeader: true }), + ); + await waitForLoad(); + + const shownShape = shapeOf(shown); + const hiddenShape = shapeOf(hidden); + + // Header parts appear only when hideHeader is false... + expect(shownShape.backButtons).toBe(1); + expect(hiddenShape.backButtons).toBe(0); + // ...and the content region is identical either way. + expect(shownShape.tabs).toBe(hiddenShape.tabs); + expect(shownShape.tabLists).toBe(hiddenShape.tabLists); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.composable.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.composable.test.tsx new file mode 100644 index 000000000..f6e8db1d0 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.composable.test.tsx @@ -0,0 +1,193 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { OrganizationMemberManagement } from '@/components/auth0/my-organization/organization-member-management.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { createMockPendingInvitation } from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks'; +import { + createMockMember, + createMockRoleOptions, +} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { OrganizationMemberManagementProps } from '@/types/my-organization/member-management/organization-member-management-types'; + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const createProps = ( + overrides?: Partial, +): OrganizationMemberManagementProps => ({ + styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + customMessages: {}, + hideHeader: false, + readOnly: false, + ...overrides, +}); + +const waitForComponentToLoad = async () => screen.findByRole('tab', { name: /tabs\.members/i }); + +describe('OrganizationMemberManagement — composability', () => { + const mockMember = createMockMember(); + const mockInvitation = createMockPendingInvitation(); + const mockRoles = createMockRoleOptions(); + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCoreClient = initMockCoreClient(); + + const apiService = mockCoreClient.getMyOrganizationApiClient(); + (apiService.organization.members.list as ReturnType).mockResolvedValue({ + data: [mockMember], + response: { next: null, total: 1 }, + }); + (apiService.organization.invitations.list as ReturnType).mockResolvedValue({ + data: [mockInvitation], + response: { next: null }, + }); + (apiService.organization.roles.list as ReturnType).mockResolvedValue({ + data: mockRoles, + response: { next: null }, + }); + (apiService.organization.configuration.get as ReturnType).mockResolvedValue({ + allowed_strategies: ['samlp', 'oidc'], + connection_deletion_behavior: 'allow', + allowed_roles: mockRoles, + }); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Tier 1 — default (backwards compatible)', () => { + it('renders the tabs and invite button when called directly', async () => { + renderWithProviders(); + await waitForComponentToLoad(); + + expect(screen.getByRole('tab', { name: /tabs\.members/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /invite_button/i })).toBeInTheDocument(); + }); + + it('omits the invite button when read-only', async () => { + renderWithProviders(); + await waitForComponentToLoad(); + + expect(screen.queryByRole('button', { name: /invite_button/i })).not.toBeInTheDocument(); + }); + }); + + describe('Tier 2 — narrow replacement via render prop', () => { + it('renders the host element and chains its onClick into the create command', async () => { + const user = userEvent.setup(); + const hostOnClick = vi.fn(); + + renderWithProviders( + + + + Invite people + + } + /> + + , + ); + await waitForComponentToLoad(); + + const hostButton = screen.getByRole('button', { name: /invite people/i }); + expect(hostButton).toBeInTheDocument(); + + await user.click(hostButton); + expect(hostOnClick).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByText(/invitation\.create\.title/i)).toBeInTheDocument(); + }); + }); + + it('skips the create command when the host cancels the event', async () => { + const user = userEvent.setup(); + + renderWithProviders( + + + e.preventDefault()}> + Invite people + + } + /> + + , + ); + await waitForComponentToLoad(); + + await user.click(screen.getByRole('button', { name: /invite people/i })); + + expect(screen.queryByText(/invitation\.create\.title/i)).not.toBeInTheDocument(); + }); + }); + + describe('Tier 3 — structural composition', () => { + it('omits the refresh region and renders host UI interleaved', async () => { + renderWithProviders( + + +
Host guidance
+ +
, + ); + await waitForComponentToLoad(); + + expect(screen.getByTestId('host-panel')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + // No Refresh part rendered => no refresh control present. + expect(screen.queryByRole('button', { name: 'refresh' })).not.toBeInTheDocument(); + }); + + it('renders a single table (parts share one model, no duplication)', async () => { + renderWithProviders( + + + + + , + ); + await waitForComponentToLoad(); + + // A duplicated model would render a second table. Parts read the single + // model provided by Root via context. + expect(screen.getAllByRole('table')).toHaveLength(1); + // The standalone Refresh part is present. + expect(screen.getByRole('button', { name: 'refresh' })).toBeInTheDocument(); + }); + }); + + describe('Tier 4 — headless alias', () => { + it('re-exports the model hook under the stable name', async () => { + const mod = await import('@/index'); + expect(mod.useOrganizationMemberManagementModel).toBe(mod.useOrganizationMemberManagement); + }); + }); + + describe('context safety', () => { + it('throws when a compound part is rendered outside Root', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + /must be rendered inside /, + ); + consoleError.mockRestore(); + }); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.parity.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.parity.test.tsx new file mode 100644 index 000000000..483366123 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.parity.test.tsx @@ -0,0 +1,189 @@ +import { within, screen } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { OrganizationMemberManagement } from '@/components/auth0/my-organization/organization-member-management.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { createMockPendingInvitation } from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks'; +import { + createMockMember, + createMockRoleOptions, +} from '@/tests/utils/__mocks__/my-organization/member-management/member.mocks'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { OrganizationMemberManagementProps } from '@/types/my-organization/member-management/organization-member-management-types'; + +/** + * Layout-parity guard. + * + * `OrganizationMemberManagement.Root` + `OrganizationMemberManagement.DefaultLayout` + * is meant to reproduce the Tier-1 (``) + * anatomy: header (honoring `hideHeader`), the members/invitations tabs, the + * refresh control, a single (active-tab) table, and the invite action. This test + * renders both paths with identical props and asserts the layout invariants + * match, so drift between the callable Tier-1 path and the composed layout is + * caught here. + * + * It intentionally compares structural invariants rather than byte-identical + * DOM: the Tier-1 header builds the invite trigger via `Header actions={[...]}` + * while the composed `Header` part builds it via `actionSlot={}`, + * so an innerHTML diff would flag intended differences as failures. + */ + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const createProps = ( + overrides?: Partial, +): OrganizationMemberManagementProps => ({ + styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + customMessages: {}, + hideHeader: false, + readOnly: false, + ...overrides, +}); + +/** Structural fingerprint of a rendered layout — the invariants that must match. */ +interface LayoutShape { + headings: number; + tabs: number; + tables: number; + refreshControls: number; + inviteButtons: number; +} + +const shapeOf = (container: HTMLElement): LayoutShape => { + const scope = within(container); + return { + // The section header emits a single (h2) heading. + headings: scope.queryAllByRole('heading').length, + // TabsList renders one `tab` per members/invitations trigger. + tabs: scope.queryAllByRole('tab').length, + // Only the active tab's content mounts, so a healthy layout has one table. + tables: scope.queryAllByRole('table').length, + // The RefreshIndicator's manual-refresh button. Queried by role/name rather + // than the `role="status"` region because the app also renders a global + // sr-only "Content loaded." status announcer that would inflate the count. + refreshControls: scope.queryAllByRole('button', { name: /refresh/i }).length, + inviteButtons: scope.queryAllByRole('button', { name: /invite_button/i }).length, + }; +}; + +describe('OrganizationMemberManagement — Tier-1 vs DefaultLayout parity', () => { + const mockMember = createMockMember(); + const mockInvitation = createMockPendingInvitation(); + const mockRoles = createMockRoleOptions(); + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCoreClient = initMockCoreClient(); + + const apiService = mockCoreClient.getMyOrganizationApiClient(); + (apiService.organization.members.list as ReturnType).mockResolvedValue({ + data: [mockMember], + response: { next: null, total: 1 }, + }); + (apiService.organization.invitations.list as ReturnType).mockResolvedValue({ + data: [mockInvitation], + response: { next: null }, + }); + (apiService.organization.roles.list as ReturnType).mockResolvedValue({ + data: mockRoles, + response: { next: null }, + }); + (apiService.organization.configuration.get as ReturnType).mockResolvedValue({ + allowed_strategies: ['samlp', 'oidc'], + connection_deletion_behavior: 'allow', + allowed_roles: mockRoles, + }); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + const renderTier1 = (props: OrganizationMemberManagementProps) => { + const { container } = renderWithProviders(); + return container; + }; + + const renderDefaultLayout = (props: OrganizationMemberManagementProps) => { + const { container } = renderWithProviders( + + + , + ); + return container; + }; + + // Both paths finish their initial load once the members tab trigger appears. + const waitForLoad = async () => screen.findAllByRole('tab', { name: /tabs\.members/i }); + + it('matches the Tier-1 anatomy with a header shown (hideHeader: false)', async () => { + const tier1 = renderTier1(createProps({ hideHeader: false })); + const composed = renderDefaultLayout(createProps({ hideHeader: false })); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // Baseline expectations for the Tier-1 path: one header heading, the two + // tabs, one active-tab table, one refresh control, one invite action. + expect(tier1Shape).toEqual({ + headings: 1, + tabs: 2, + tables: 1, + refreshControls: 1, + inviteButtons: 1, + }); + // Header must actually be present (not merely a matching count). + expect(within(tier1).getByRole('heading', { name: /header\.title/i })).toBeInTheDocument(); + // DefaultLayout must reproduce it exactly when the header is shown. + expect(composedShape).toEqual(tier1Shape); + }); + + it('matches the Tier-1 anatomy with the header gated (hideHeader: true)', async () => { + const tier1 = renderTier1(createProps({ hideHeader: true })); + const composed = renderDefaultLayout(createProps({ hideHeader: true })); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // The Tier-1 path gates its header (and the invite action lives in it) while + // keeping the tab/table/refresh body intact. + expect(tier1Shape.headings).toBe(0); + expect(tier1Shape.inviteButtons).toBe(0); + expect(tier1Shape.tabs).toBe(2); + expect(tier1Shape.tables).toBe(1); + expect(tier1Shape.refreshControls).toBe(1); + + // DefaultLayout must honor hideHeader identically — the header (and its + // invite action) are gated in both paths, body unchanged. + expect(composedShape).toEqual(tier1Shape); + }); + + it('toggles the header purely on hideHeader in DefaultLayout', async () => { + const shown = renderDefaultLayout(createProps({ hideHeader: false })); + const hidden = renderDefaultLayout(createProps({ hideHeader: true })); + await waitForLoad(); + + expect(shapeOf(shown).headings).toBe(1); + expect(shapeOf(shown).inviteButtons).toBe(1); + expect(shapeOf(hidden).headings).toBe(0); + expect(shapeOf(hidden).inviteButtons).toBe(0); + }); + + it('shows the DefaultLayout header when hideHeader is false', async () => { + const shown = renderDefaultLayout(createProps({ hideHeader: false })); + await waitForLoad(); + + expect(shapeOf(shown).headings).toBe(1); + expect(within(shown).getByRole('heading', { name: /header\.title/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-create.composable.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-create.composable.test.tsx new file mode 100644 index 000000000..9c058f558 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-create.composable.test.tsx @@ -0,0 +1,148 @@ +import { AVAILABLE_STRATEGY_LIST, idpConfigQueryKeys } from '@auth0/universal-components-core'; +import type { QueryClient } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { SsoProviderCreate } from '@/components/auth0/my-organization/sso-provider-create.composable'; +import * as useConfigModule from '@/hooks/my-organization/shared/services/use-config-service'; +import * as useIdpConfigModule from '@/hooks/my-organization/shared/services/use-idp-config-service'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { createMockUseConfig } from '@/tests/utils/__mocks__/my-organization/config/config.mocks'; +import { createMockUseIdpConfig } from '@/tests/utils/__mocks__/my-organization/idp-management/idp-config.mocks'; +import { createTestQueryClient, renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { SsoProviderCreateProps } from '@/types/my-organization/idp-management/sso-provider/sso-provider-create-types'; + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const createMockSsoProviderCreateProps = ( + overrides?: Partial, +): SsoProviderCreateProps => ({ + createAction: { + disabled: false, + onBefore: vi.fn(() => true), + onAfter: vi.fn(), + }, + customMessages: {}, + styling: { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, + }, + backButton: undefined, + onNext: undefined, + onPrevious: undefined, + schema: undefined, + ...overrides, +}); + +const createMockBackButton = () => ({ + onClick: vi.fn(), +}); + +const waitForComponentToLoad = async () => { + await screen.findByTestId('sso-provider-create-content'); + + return await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); +}; + +const waitForStrategyButtons = async () => { + const wizardContent = await screen.findByTestId('sso-provider-create-content'); + + await waitFor(() => { + const strategyButtons = wizardContent.querySelectorAll('button[class*="justify-start"]'); + expect(strategyButtons.length).toBeGreaterThan(0); + }); + + return wizardContent.querySelectorAll('button[class*="justify-start"]'); +}; + +const createMockIdpConfig = () => ({ + organization: { + can_set_show_as_button: true, + can_set_assign_membership_on_login: true, + }, + strategies: AVAILABLE_STRATEGY_LIST, +}); + +describe('SsoProviderCreate — composability', () => { + let mockCoreClient: ReturnType; + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockCoreClient = initMockCoreClient(); + queryClient = createTestQueryClient(); + queryClient.setQueryData(idpConfigQueryKeys.config(), createMockIdpConfig()); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + + vi.spyOn(useConfigModule, 'useConfig').mockReturnValue( + createMockUseConfig({ + config: { + connection_deletion_behavior: 'allow', + allowed_strategies: ['adfs', 'okta', 'samlp'], + }, + filteredStrategies: ['adfs', 'okta', 'samlp'], + }), + ); + + vi.spyOn(useIdpConfigModule, 'useIdpConfig').mockReturnValue(createMockUseIdpConfig()); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Tier 1 — default (backwards compatible)', () => { + it('renders the wizard and strategy step when called directly', async () => { + renderWithProviders(, { + queryClient, + }); + + await waitForComponentToLoad(); + + expect(screen.getByTestId('sso-provider-create-content')).toBeInTheDocument(); + const strategyButtons = await waitForStrategyButtons(); + expect(strategyButtons.length).toBeGreaterThan(0); + }); + }); + + describe('Tier 3 — structural composition', () => { + it('renders host UI interleaved with a single wizard instance', async () => { + const mockBackButton = createMockBackButton(); + + renderWithProviders( + +
Host guidance
+ +
, + { queryClient }, + ); + + await waitForComponentToLoad(); + + expect(screen.getByTestId('host-panel')).toBeInTheDocument(); + // Parts read the single model provided by Root via context => one wizard. + expect(screen.getAllByTestId('sso-provider-create-content')).toHaveLength(1); + const strategyButtons = await waitForStrategyButtons(); + expect(strategyButtons.length).toBeGreaterThan(0); + }); + }); + + describe('context safety', () => { + it('throws when Content is rendered outside Root', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + /must be rendered inside /, + ); + consoleError.mockRestore(); + }); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-create.parity.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-create.parity.test.tsx new file mode 100644 index 000000000..f8b7782ad --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-create.parity.test.tsx @@ -0,0 +1,200 @@ +import { AVAILABLE_STRATEGY_LIST, idpConfigQueryKeys } from '@auth0/universal-components-core'; +import type { QueryClient } from '@tanstack/react-query'; +import { within, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { SsoProviderCreate } from '@/components/auth0/my-organization/sso-provider-create.composable'; +import * as useConfigModule from '@/hooks/my-organization/shared/services/use-config-service'; +import * as useIdpConfigModule from '@/hooks/my-organization/shared/services/use-idp-config-service'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { createMockUseConfig } from '@/tests/utils/__mocks__/my-organization/config/config.mocks'; +import { createMockUseIdpConfig } from '@/tests/utils/__mocks__/my-organization/idp-management/idp-config.mocks'; +import { createTestQueryClient, renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { SsoProviderCreateProps } from '@/types/my-organization/idp-management/sso-provider/sso-provider-create-types'; + +/** + * Layout-parity guard — no-header wizard exception. + * + * `SsoProviderCreate` is the deliberate exception to the header-parity the + * table-style components enforce: it is a multi-step *wizard* whose Next / + * Previous / Complete chrome is owned by the shared ``, so there is + * **no `Header` part and no `hideHeader` flag** — the view always renders its + * own header (see the HURDLE note in `sso-provider-create.composable.tsx`). + * A standalone header slot would be a hollow control with no navigation behind + * it, so the composable offers only structural parts (`Root` / `Content` / + * `DefaultLayout`), never a header/action/refresh part. + * + * Parity here therefore means: `` + + * `` must reproduce the Tier-1 + * (``) wizard anatomy — the always-on header + * heading, exactly one wizard content region (no duplication), the first + * step's strategy fields, and the optional back button — with **identical + * props**. We compare structural invariants (equal counts), not byte-identical + * DOM, so intentional wrapper differences between the two paths do not flag as + * failures. There is no `hideHeader` case to assert; instead we cover default + * props and a second meaningful variant (`backButton`), and confirm the + * composed path never duplicates the wizard content. + */ + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const createProps = (overrides?: Partial): SsoProviderCreateProps => ({ + createAction: { + disabled: false, + onBefore: vi.fn(() => true), + onAfter: vi.fn(), + }, + customMessages: {}, + styling: { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, + }, + backButton: undefined, + onNext: undefined, + onPrevious: undefined, + schema: undefined, + ...overrides, +}); + +const createMockIdpConfig = () => ({ + organization: { + can_set_show_as_button: true, + can_set_assign_membership_on_login: true, + }, + strategies: AVAILABLE_STRATEGY_LIST, +}); + +/** Structural fingerprint of a rendered wizard — the invariants that must match. */ +interface WizardShape { + /** The always-on header heading (this component has no `hideHeader`). */ + headings: number; + /** Wizard content region(s); must be exactly 1 — parts share one model. */ + contentRegions: number; + /** First-step strategy buttons (`ProviderSelect`). */ + strategyButtons: number; + /** The header's optional back button (present only when `backButton` is set). */ + backButtons: number; +} + +const shapeOf = (container: HTMLElement): WizardShape => { + const scope = within(container); + return { + headings: scope.queryAllByRole('heading').length, + contentRegions: scope.queryAllByTestId('sso-provider-create-content').length, + // Strategy tiles render as left-aligned buttons in the first wizard step. + strategyButtons: container.querySelectorAll('button[class*="justify-start"]').length, + // Header back button carries the `mb-3` spacing class unique to it here. + backButtons: container.querySelectorAll('button[class*="mb-3"]').length, + }; +}; + +describe('SsoProviderCreate — Tier-1 vs DefaultLayout parity (no-header wizard)', () => { + let mockCoreClient: ReturnType; + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockCoreClient = initMockCoreClient(); + queryClient = createTestQueryClient(); + queryClient.setQueryData(idpConfigQueryKeys.config(), createMockIdpConfig()); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + + vi.spyOn(useConfigModule, 'useConfig').mockReturnValue( + createMockUseConfig({ + config: { + connection_deletion_behavior: 'allow', + allowed_strategies: ['adfs', 'okta', 'samlp'], + }, + filteredStrategies: ['adfs', 'okta', 'samlp'], + }), + ); + + vi.spyOn(useIdpConfigModule, 'useIdpConfig').mockReturnValue(createMockUseIdpConfig()); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + const renderTier1 = (props: SsoProviderCreateProps) => { + const { container } = renderWithProviders(, { queryClient }); + return container; + }; + + // Canonical "default layout" composition: Root shares one model with the + // parts, and DefaultLayout simply renders Content. + const renderDefaultLayout = (props: SsoProviderCreateProps) => { + const { container } = renderWithProviders( + + + , + { queryClient }, + ); + return container; + }; + + const waitForWizard = async (container: HTMLElement) => + waitFor(() => { + expect(within(container).queryByTestId('sso-provider-create-content')).toBeInTheDocument(); + expect(container.querySelectorAll('button[class*="justify-start"]').length).toBeGreaterThan( + 0, + ); + }); + + it('matches the Tier-1 wizard anatomy with default props', async () => { + const tier1 = renderTier1(createProps()); + const composed = renderDefaultLayout(createProps()); + await waitForWizard(tier1); + await waitForWizard(composed); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // Baseline: header always renders (no hideHeader), exactly one wizard + // content region, strategy step populated, no back button by default. + expect(tier1Shape.headings).toBeGreaterThanOrEqual(1); + expect(tier1Shape.contentRegions).toBe(1); + expect(tier1Shape.strategyButtons).toBeGreaterThan(0); + expect(tier1Shape.backButtons).toBe(0); + + // DefaultLayout must reproduce the Tier-1 shape exactly (no duplication). + expect(composedShape).toEqual(tier1Shape); + }); + + it('matches the Tier-1 wizard anatomy with a back button (meaningful variant)', async () => { + const tier1 = renderTier1(createProps({ backButton: { onClick: vi.fn() } })); + const composed = renderDefaultLayout(createProps({ backButton: { onClick: vi.fn() } })); + await waitForWizard(tier1); + await waitForWizard(composed); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // The back button now renders inside the (always-on) header... + expect(tier1Shape.backButtons).toBe(1); + expect(tier1Shape.headings).toBeGreaterThanOrEqual(1); + expect(tier1Shape.contentRegions).toBe(1); + // ...and DefaultLayout must honor it identically. + expect(composedShape).toEqual(tier1Shape); + }); + + it('renders the wizard content region exactly once when interleaved with host UI', async () => { + const { container } = renderWithProviders( + +
Host guidance
+ +
, + { queryClient }, + ); + await waitForWizard(container); + + // Interleaving host UI must not duplicate the wizard: parts share one model. + expect(within(container).getByTestId('host-panel')).toBeInTheDocument(); + expect(shapeOf(container).contentRegions).toBe(1); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-edit.composable.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-edit.composable.test.tsx new file mode 100644 index 000000000..10d23402d --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-edit.composable.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { SsoProviderEdit } from '@/components/auth0/my-organization/sso-provider-edit.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { createMockIdentityProvider } from '@/tests/utils/__mocks__/my-organization/domain-management/domain.mocks'; +import { createMockSsoProviderEditProps } from '@/tests/utils/__mocks__/my-organization/idp-management/sso-provider-edit/sso-provider-edit.mocks'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const waitForComponentToLoad = async () => { + return await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); +}; + +describe('SsoProviderEdit — composability', () => { + const mockProvider = createMockIdentityProvider(); + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCoreClient = initMockCoreClient(); + + const organizationApi = mockCoreClient.getMyOrganizationApiClient().organization; + Object.defineProperty(organizationApi, 'domains', { + value: { + getAll: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + }, + writable: true, + configurable: true, + }); + + Object.defineProperty(organizationApi, 'identityProviders', { + value: { + ...organizationApi.identityProviders, + get: vi.fn().mockResolvedValue(mockProvider), + update: vi.fn().mockResolvedValue(mockProvider), + delete: vi.fn().mockResolvedValue({}), + }, + writable: true, + configurable: true, + }); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Tier 1 — default (backwards compatible)', () => { + it('renders the editor with the provider name header and tabs when called directly', async () => { + renderWithProviders(); + await waitForComponentToLoad(); + + expect(screen.getByText(mockProvider.display_name!)).toBeInTheDocument(); + expect(screen.getByText(/tabs.sso.name/i)).toBeInTheDocument(); + expect(screen.getByText(/tabs.domains.name/i)).toBeInTheDocument(); + }); + }); + + describe('Tier 3 — structural composition', () => { + it('renders host UI interleaved and a single editor instance sharing one model', async () => { + renderWithProviders( + + +
Host guidance
+ +
, + ); + await waitForComponentToLoad(); + + // Host UI is interleaved with the editor parts. + expect(screen.getByTestId('host-panel')).toBeInTheDocument(); + + // Header title and tabs render exactly once => parts share Root's model, + // no duplicated editor instance. + expect(screen.getByText(mockProvider.display_name!)).toBeInTheDocument(); + expect(screen.getAllByRole('tablist')).toHaveLength(1); + + // The header's single enable/disable toggle is present. + const switches = screen.getAllByRole('switch'); + expect(switches.length).toBeGreaterThan(0); + }); + }); + + describe('context safety', () => { + it('throws when a compound part is rendered outside Root', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + /must be rendered inside /, + ); + consoleError.mockRestore(); + }); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-edit.parity.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-edit.parity.test.tsx new file mode 100644 index 000000000..b3fd5d2fa --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-edit.parity.test.tsx @@ -0,0 +1,178 @@ +import { within, waitFor, screen } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { SsoProviderEdit } from '@/components/auth0/my-organization/sso-provider-edit.composable'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { createMockIdentityProvider } from '@/tests/utils/__mocks__/my-organization/domain-management/domain.mocks'; +import { createMockSsoProviderEditProps } from '@/tests/utils/__mocks__/my-organization/idp-management/sso-provider-edit/sso-provider-edit.mocks'; +import { renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { SsoProviderEditProps } from '@/types/my-organization/idp-management/sso-provider/sso-provider-edit-types'; + +/** + * Layout-parity guard. + * + * `SsoProviderEdit.Root` + `SsoProviderEdit.DefaultLayout` must reproduce the + * Tier-1 (``) anatomy: header (honoring + * `hideHeader`) with the enable/disable toggle SWITCH, and the tabbed editor + * body (single tablist + its tabs). This test renders both paths with identical + * props and asserts the layout invariants match, so regressions like a + * `hideHeader` drift in `DefaultLayout` (a header rendered when it should have + * been gated) fail here. + * + * It intentionally compares structural invariants rather than byte-identical + * DOM. In composition the header is owned by the `Header` part while `Content` + * renders the view with its header suppressed (`hideHeader` forced true); the + * Tier-1 view renders the header inline. Both funnel through the shared + * {@link Header} with the same `switch` action, so the invariant counts match + * even though the render trees are assembled differently. + * + * The enable/disable toggle is a SWITCH (not a button). The a11y fix gave it an + * `aria-label` (a translated key in tests, e.g. `header.enable_provider_...`), + * so the header toggle is queryable by accessible name and distinguishable from + * any switches inside the tabs. `headerSwitches` counts exactly that control. + */ + +mockToast(); +const { initMockCoreClient } = mockCore(); + +/** Structural fingerprint of a rendered layout — the invariants that must match. */ +interface LayoutShape { + headings: number; + /** All switches in the subtree (header toggle plus any inside the tabs). */ + switches: number; + /** The header enable/disable toggle specifically, by its aria-label. */ + headerSwitches: number; + tablists: number; + tabs: number; + /** Whether the header wrapper keeps its `mb-8` spacing (when a header renders). */ + headerHasSpacing: boolean; +} + +/** The header toggle's accessible name is the enable/disable tooltip key. */ +const HEADER_TOGGLE_NAME = /header\.(enable|disable)_provider/i; + +const shapeOf = (container: HTMLElement): LayoutShape => { + const scope = within(container); + const headerSwitches = scope.queryAllByRole('switch', { name: HEADER_TOGGLE_NAME }); + return { + headings: scope.queryAllByRole('heading').length, + switches: scope.queryAllByRole('switch').length, + headerSwitches: headerSwitches.length, + tablists: scope.queryAllByRole('tablist').length, + tabs: scope.queryAllByRole('tab').length, + headerHasSpacing: headerSwitches.some((s) => s.closest('.mb-8') != null), + }; +}; + +describe('SsoProviderEdit — Tier-1 vs DefaultLayout parity', () => { + const mockProvider = createMockIdentityProvider(); + let mockCoreClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockCoreClient = initMockCoreClient(); + + const organizationApi = mockCoreClient.getMyOrganizationApiClient().organization; + Object.defineProperty(organizationApi, 'domains', { + value: { + getAll: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + }, + writable: true, + configurable: true, + }); + + Object.defineProperty(organizationApi, 'identityProviders', { + value: { + ...organizationApi.identityProviders, + get: vi.fn().mockResolvedValue(mockProvider), + update: vi.fn().mockResolvedValue(mockProvider), + delete: vi.fn().mockResolvedValue({}), + }, + writable: true, + configurable: true, + }); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ + coreClient: mockCoreClient, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + const renderTier1 = (props: SsoProviderEditProps) => { + const { container } = renderWithProviders(); + return container; + }; + + const renderDefaultLayout = (props: SsoProviderEditProps) => { + const { container } = renderWithProviders( + + + , + ); + return container; + }; + + const waitForLoad = async () => + waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument()); + + it('matches the Tier-1 anatomy with a header shown (hideHeader: false)', async () => { + const tier1 = renderTier1(createMockSsoProviderEditProps({ hideHeader: false })); + const composed = renderDefaultLayout(createMockSsoProviderEditProps({ hideHeader: false })); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // Baseline expectations for the Tier-1 path: the header renders exactly one + // enable/disable toggle switch (with its aria-label and mb-8 spacing) and a + // single tabbed body. + expect(tier1Shape.headerSwitches).toBe(1); + expect(tier1Shape.headerHasSpacing).toBe(true); + expect(tier1Shape.tablists).toBe(1); + expect(tier1Shape.headings).toBeGreaterThan(0); + // DefaultLayout must reproduce the full fingerprint exactly. + expect(composedShape).toEqual(tier1Shape); + }); + + it('matches the Tier-1 anatomy with the header gated (hideHeader: true)', async () => { + const tier1 = renderTier1(createMockSsoProviderEditProps({ hideHeader: true })); + const composed = renderDefaultLayout(createMockSsoProviderEditProps({ hideHeader: true })); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // The header (and thus its enable/disable toggle) must be gone in the Tier-1 + // path, while the tabbed body remains... + expect(tier1Shape.headerSwitches).toBe(0); + expect(tier1Shape.headerHasSpacing).toBe(false); + expect(tier1Shape.tablists).toBe(1); + // ...and DefaultLayout must honor hideHeader identically (the regression). + expect(composedShape).toEqual(tier1Shape); + }); + + it('toggles the header purely on hideHeader in DefaultLayout', async () => { + const shown = renderDefaultLayout(createMockSsoProviderEditProps({ hideHeader: false })); + const hidden = renderDefaultLayout(createMockSsoProviderEditProps({ hideHeader: true })); + await waitForLoad(); + + const shownShape = shapeOf(shown); + const hiddenShape = shapeOf(hidden); + + // Only the header (its labeled toggle switch + spacing) is affected... + expect(shownShape.headerSwitches).toBe(1); + expect(hiddenShape.headerSwitches).toBe(0); + expect(shownShape.headerHasSpacing).toBe(true); + expect(hiddenShape.headerHasSpacing).toBe(false); + // ...the editor body is unchanged by hideHeader. + expect(hiddenShape.tablists).toBe(shownShape.tablists); + expect(hiddenShape.tabs).toBe(shownShape.tabs); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-table.composable.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-table.composable.test.tsx new file mode 100644 index 000000000..0739d382c --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-table.composable.test.tsx @@ -0,0 +1,225 @@ +import { idpConfigQueryKeys } from '@auth0/universal-components-core'; +import type { QueryClient } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { SsoProviderTable } from '@/components/auth0/my-organization/sso-provider-table.composable'; +import * as useConfigModule from '@/hooks/my-organization/shared/services/use-config-service'; +import * as useIdpConfigModule from '@/hooks/my-organization/shared/services/use-idp-config-service'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { createMockUseConfig } from '@/tests/utils/__mocks__/my-organization/config/config.mocks'; +import { createMockIdentityProvider } from '@/tests/utils/__mocks__/my-organization/domain-management/domain.mocks'; +import { createMockUseIdpConfig } from '@/tests/utils/__mocks__/my-organization/idp-management/idp-config.mocks'; +import { createTestQueryClient, renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { SsoProviderTableProps } from '@/types/my-organization/idp-management/sso-provider/sso-provider-table-types'; + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const createProps = (overrides?: Partial): SsoProviderTableProps => ({ + customMessages: {}, + styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + readOnly: false, + createAction: { disabled: false, onBefore: vi.fn(() => true), onAfter: vi.fn() }, + editAction: { disabled: false, onBefore: vi.fn(() => true), onAfter: vi.fn() }, + deleteAction: undefined, + deleteFromOrganizationAction: {}, + enableProviderAction: undefined, + ...overrides, +}); + +const createMockIdpConfig = () => ({ + organization: { + can_set_show_as_button: true, + can_set_assign_membership_on_login: true, + }, + strategies: { + adfs: { enabled_features: [], provisioning_methods: [] }, + googleapps: { enabled_features: [], provisioning_methods: [] }, + oidc: { enabled_features: [], provisioning_methods: [] }, + okta: { enabled_features: [], provisioning_methods: [] }, + pingfederate: { enabled_features: [], provisioning_methods: [] }, + samlp: { enabled_features: [], provisioning_methods: [] }, + waad: { enabled_features: [], provisioning_methods: [] }, + }, +}); + +const waitForLoad = async () => + waitFor(() => expect(screen.queryByText(/loading.../i)).not.toBeInTheDocument()); + +describe('SsoProviderTable — composability', () => { + const mockProvider = createMockIdentityProvider(); + let mockCoreClient: ReturnType; + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockCoreClient = initMockCoreClient(); + queryClient = createTestQueryClient(); + queryClient.setQueryData(idpConfigQueryKeys.config(), createMockIdpConfig()); + + const apiService = mockCoreClient.getMyOrganizationApiClient(); + (apiService.organization.identityProviders.list as ReturnType).mockResolvedValue({ + identity_providers: [mockProvider], + }); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: mockCoreClient }); + vi.spyOn(useConfigModule, 'useConfig').mockReturnValue(createMockUseConfig()); + vi.spyOn(useIdpConfigModule, 'useIdpConfig').mockReturnValue(createMockUseIdpConfig()); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Tier 1 — default (backwards compatible)', () => { + it('renders the table and create button when called directly', async () => { + renderWithProviders(, { queryClient }); + await waitForLoad(); + + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument(); + }); + }); + + describe('Tier 2 — narrow replacement via render prop', () => { + it('renders the host element and chains its onClick into the create command', async () => { + const user = userEvent.setup(); + const hostOnClick = vi.fn(); + const createAction = { disabled: false, onBefore: vi.fn(() => true), onAfter: vi.fn() }; + + renderWithProviders( + + + + Add connection + + } + /> + + , + { queryClient }, + ); + await waitForLoad(); + + const hostButton = screen.getByRole('button', { name: /add connection/i }); + expect(hostButton).toBeInTheDocument(); + + await user.click(hostButton); + expect(hostOnClick).toHaveBeenCalledTimes(1); + expect(createAction.onAfter).toHaveBeenCalledTimes(1); + }); + + it('skips the create command when the host cancels the event', async () => { + const user = userEvent.setup(); + const createAction = { disabled: false, onBefore: vi.fn(() => true), onAfter: vi.fn() }; + + renderWithProviders( + + + e.preventDefault()}> + Add connection + + } + /> + + , + { queryClient }, + ); + await waitForLoad(); + + await user.click(screen.getByRole('button', { name: /add connection/i })); + expect(createAction.onAfter).not.toHaveBeenCalled(); + }); + }); + + describe('Tier 3 — structural composition', () => { + it('omits the refresh region and renders host UI interleaved', async () => { + renderWithProviders( + + +
Host guidance
+ +
, + { queryClient }, + ); + await waitForLoad(); + + expect(screen.getByTestId('host-panel')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + // No Refresh part rendered => no refresh control present. + expect(screen.queryByRole('button', { name: /refresh/i })).not.toBeInTheDocument(); + }); + + it('renders a single table (parts share one model, no duplication)', async () => { + renderWithProviders( + + + + + , + { queryClient }, + ); + await waitForLoad(); + + // A duplicated model would render a second table/header. Parts read the + // single model provided by Root via context. + expect(screen.getAllByRole('table')).toHaveLength(1); + await waitFor(() => expect(screen.getByText(mockProvider.name!)).toBeInTheDocument()); + }); + }); + + describe('Tier 4 — headless alias', () => { + it('re-exports the model hook under the stable name', async () => { + const mod = await import('@/index'); + expect(mod.useSsoProviderTableModel).toBe(mod.useSsoProviderTable); + }); + }); + + describe('context safety', () => { + it('throws when a compound part is rendered outside Root', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + /must be rendered inside /, + ); + consoleError.mockRestore(); + }); + }); + + describe('dev-mode required-part warning', () => { + it('warns, naming Content, when Root is composed without it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + renderWithProviders( + + + , + { queryClient }, + ); + await waitForLoad(); + + await waitFor(() => expect(warn).toHaveBeenCalled()); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('')); + }); + + it('does not warn for DefaultLayout, which renders Content', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + renderWithProviders( + + + , + { queryClient }, + ); + await waitForLoad(); + + expect(warn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-table.parity.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-table.parity.test.tsx new file mode 100644 index 000000000..6b719c228 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-table.parity.test.tsx @@ -0,0 +1,175 @@ +import { idpConfigQueryKeys } from '@auth0/universal-components-core'; +import type { QueryClient } from '@tanstack/react-query'; +import { within, waitFor, screen } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { SsoProviderTable } from '@/components/auth0/my-organization/sso-provider-table.composable'; +import * as useConfigModule from '@/hooks/my-organization/shared/services/use-config-service'; +import * as useIdpConfigModule from '@/hooks/my-organization/shared/services/use-idp-config-service'; +import * as useCoreClientModule from '@/hooks/shared/use-core-client'; +import { createMockUseConfig } from '@/tests/utils/__mocks__/my-organization/config/config.mocks'; +import { createMockIdentityProvider } from '@/tests/utils/__mocks__/my-organization/domain-management/domain.mocks'; +import { createMockUseIdpConfig } from '@/tests/utils/__mocks__/my-organization/idp-management/idp-config.mocks'; +import { createTestQueryClient, renderWithProviders } from '@/tests/utils/test-provider'; +import { mockCore, mockToast } from '@/tests/utils/test-setup'; +import type { SsoProviderTableProps } from '@/types/my-organization/idp-management/sso-provider/sso-provider-table-types'; + +/** + * Layout-parity guard. + * + * `SsoProviderTable.Root` + `SsoProviderTable.DefaultLayout` must reproduce the + * Tier-1 (``) anatomy: header (honoring + * `hideHeader`), refresh region, single table, and create action. This test + * renders both paths with identical props and asserts the layout invariants + * match, so regressions like the historical `hideHeader` drift in + * `DefaultLayout` (a header rendered when it should have been gated) fail here. + * + * It intentionally compares structural invariants rather than byte-identical + * DOM: the compound header action (`CreateAction` + `PermissionDeniedTooltip`) + * and the Tier-1 `Header actions={[...]}` path build the trigger differently by + * design, so an innerHTML diff would flag intended differences as failures. + */ + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const createProps = (overrides?: Partial): SsoProviderTableProps => ({ + customMessages: {}, + styling: { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + readOnly: false, + createAction: { disabled: false, onBefore: vi.fn(() => true), onAfter: vi.fn() }, + editAction: { disabled: false, onBefore: vi.fn(() => true), onAfter: vi.fn() }, + deleteAction: undefined, + deleteFromOrganizationAction: {}, + enableProviderAction: undefined, + ...overrides, +}); + +const createMockIdpConfig = () => ({ + organization: { + can_set_show_as_button: true, + can_set_assign_membership_on_login: true, + }, + strategies: { + adfs: { enabled_features: [], provisioning_methods: [] }, + googleapps: { enabled_features: [], provisioning_methods: [] }, + oidc: { enabled_features: [], provisioning_methods: [] }, + okta: { enabled_features: [], provisioning_methods: [] }, + pingfederate: { enabled_features: [], provisioning_methods: [] }, + samlp: { enabled_features: [], provisioning_methods: [] }, + waad: { enabled_features: [], provisioning_methods: [] }, + }, +}); + +/** Structural fingerprint of a rendered layout — the invariants that must match. */ +interface LayoutShape { + headings: number; + tables: number; + refreshRegions: number; + createButtons: number; + /** Whether the header wrapper keeps its `mb-8` spacing (when a header renders). */ + headerHasSpacing: boolean; +} + +const shapeOf = (container: HTMLElement): LayoutShape => { + const scope = within(container); + const headings = scope.queryAllByRole('heading'); + return { + headings: headings.length, + tables: scope.queryAllByRole('table').length, + // RefreshIndicator renders a `role="status"` region. + refreshRegions: scope.queryAllByRole('status').length, + createButtons: scope.queryAllByRole('button', { name: /create/i }).length, + headerHasSpacing: headings.some((h) => h.closest('.mb-8') != null), + }; +}; + +describe('SsoProviderTable — Tier-1 vs DefaultLayout parity', () => { + const mockProvider = createMockIdentityProvider(); + let mockCoreClient: ReturnType; + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockCoreClient = initMockCoreClient(); + queryClient = createTestQueryClient(); + queryClient.setQueryData(idpConfigQueryKeys.config(), createMockIdpConfig()); + + const apiService = mockCoreClient.getMyOrganizationApiClient(); + (apiService.organization.identityProviders.list as ReturnType).mockResolvedValue({ + identity_providers: [mockProvider], + }); + + vi.spyOn(useCoreClientModule, 'useCoreClient').mockReturnValue({ coreClient: mockCoreClient }); + vi.spyOn(useConfigModule, 'useConfig').mockReturnValue(createMockUseConfig()); + vi.spyOn(useIdpConfigModule, 'useIdpConfig').mockReturnValue(createMockUseIdpConfig()); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + const renderTier1 = (props: SsoProviderTableProps) => { + const { container } = renderWithProviders(, { queryClient }); + return container; + }; + + const renderDefaultLayout = (props: SsoProviderTableProps) => { + const { container } = renderWithProviders( + + + , + { queryClient }, + ); + return container; + }; + + const waitForLoad = async () => + waitFor(() => expect(screen.queryByText(/loading.../i)).not.toBeInTheDocument()); + + it('matches the Tier-1 anatomy with a header shown (hideHeader: false)', async () => { + const tier1 = renderTier1(createProps({ hideHeader: false })); + const composed = renderDefaultLayout(createProps({ hideHeader: false })); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // Baseline expectations for the Tier-1 path: one header heading, one table, + // one refresh region, one create action, spacing preserved. + expect(tier1Shape).toEqual({ + headings: 1, + tables: 1, + refreshRegions: 1, + createButtons: 1, + headerHasSpacing: true, + }); + // DefaultLayout must reproduce it exactly. + expect(composedShape).toEqual(tier1Shape); + }); + + it('matches the Tier-1 anatomy with the header gated (hideHeader: true)', async () => { + const tier1 = renderTier1(createProps({ hideHeader: true })); + const composed = renderDefaultLayout(createProps({ hideHeader: true })); + await waitForLoad(); + + const tier1Shape = shapeOf(tier1); + const composedShape = shapeOf(composed); + + // The header (and its create action) must be gone in the Tier-1 path... + expect(tier1Shape.headings).toBe(0); + expect(tier1Shape.createButtons).toBe(0); + expect(tier1Shape.tables).toBe(1); + // ...and DefaultLayout must honor hideHeader identically (the regression). + expect(composedShape).toEqual(tier1Shape); + }); + + it('toggles the header purely on hideHeader in DefaultLayout', async () => { + const shown = renderDefaultLayout(createProps({ hideHeader: false })); + const hidden = renderDefaultLayout(createProps({ hideHeader: true })); + await waitForLoad(); + + expect(shapeOf(shown).headings).toBe(1); + expect(shapeOf(hidden).headings).toBe(0); + }); +}); diff --git a/packages/react/src/components/auth0/my-organization/domain-table.composable.tsx b/packages/react/src/components/auth0/my-organization/domain-table.composable.tsx new file mode 100644 index 000000000..0291d280f --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/domain-table.composable.tsx @@ -0,0 +1,369 @@ +/** + * Progressive-composability layer for {@link DomainTable}. + * + * Adds compound sub-components (`Root`, `DefaultLayout`, `Header`, + * `CreateAction`, `Refresh`, `Content`) on top of the existing container/view + * split, with zero breaking changes to the Tier-1 default usage + * (``). + * + * Tiers: + * - Tier 1 (default): `` + * - Tier 2 (narrow): `` + * `} />` ... + * - Tier 3 (structural): compose `Header` / `Refresh` / `Content` freely, interleaving host UI. + * - Tier 4 (headless): `useDomainTableModel(options)` — see index re-export. + * + * @module domain-table.composable + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import { Plus } from 'lucide-react'; +import * as React from 'react'; + +import { + DomainTable as DomainTableDefault, + DomainTableView, +} from '@/components/auth0/my-organization/domain-table'; +import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper'; +import { Header } from '@/components/auth0/shared/header'; +import { PermissionDeniedTooltip } from '@/components/auth0/shared/permission-denied-tooltip'; +import { RefreshIndicator } from '@/components/auth0/shared/refresh-indicator'; +import { StyledScope } from '@/components/auth0/shared/styled-scope'; +import { Button } from '@/components/ui/button'; +import { useDomainTable } from '@/hooks/my-organization/use-domain-table'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { createComponentContext, mergeRenderProp } from '@/lib/composability'; +import { cn } from '@/lib/utils'; +import type { + DomainTableProps, + UseDomainTableResult, +} from '@/types/my-organization/domain-management/domain-table-types'; + +/** Value shared from `Root` to every compound part. */ +interface DomainTableComposition { + model: UseDomainTableResult; + props: DomainTableProps; +} + +const [DomainTableContext, useDomainTableContext, parts] = + createComponentContext('DomainTable', { + requiredParts: ['Content'], + }); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** + * Stable default for `customMessages`. Hoisted to module scope so an omitted + * prop yields the same reference every render (an inline `{}` would allocate a + * fresh object each render and defeat the composition memo + translator memo). + */ +const EMPTY_CUSTOM_MESSAGES: NonNullable = {}; + +/** Props for {@link Root}. Mirrors {@link DomainTableProps} plus children. */ +export interface DomainTableRootProps extends DomainTableProps { + children?: React.ReactNode; +} + +/** + * Composition boundary. Runs the model hook once and shares it with all + * compound parts, then wraps children in the themed scope + loading gate. + * @param props - {@link DomainTableRootProps} + * @returns The provider-wrapped subtree. + */ +function Root({ + children, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, + readOnly = false, + schema, + hideHeader, + createAction, + verifyAction, + deleteAction, + associateToProviderAction, + deleteFromProviderAction, + onOpenProvider, + onCreateProvider, +}: DomainTableRootProps) { + useTelemetry('domain-management'); + + const model = useDomainTable({ + createAction, + verifyAction, + deleteAction, + associateToProviderAction, + deleteFromProviderAction, + customMessages, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + // Key the composition on concrete prop fields, not the `props` container: a + // rest-spread (`{ ...props }`) allocates a new object every render and would + // defeat this memo. With `model` now memoized in the hook, the context value + // is stable across renders unless a real input changes. + const composition = React.useMemo( + () => ({ + model, + props: { + styling, + customMessages, + schema, + readOnly, + hideHeader, + createAction, + verifyAction, + deleteAction, + associateToProviderAction, + deleteFromProviderAction, + onOpenProvider, + onCreateProvider, + }, + }), + [ + model, + styling, + customMessages, + schema, + readOnly, + hideHeader, + createAction, + verifyAction, + deleteAction, + associateToProviderAction, + deleteFromProviderAction, + onOpenProvider, + onCreateProvider, + ], + ); + + return ( + + + + {children} + + + + ); +} + +Root.displayName = 'DomainTable.Root'; + +/** Props for {@link CreateAction}. */ +export interface DomainTableCreateActionProps { + /** Host element to render in place of the default UIC button. */ + render?: React.ReactElement; +} + +/** + * The create-domain trigger. Renders the default UIC button, or a + * host-supplied element via `render`, wired to the model's create command. + * Disabled state follows the same rules as the Tier-1 header button. + * @param props - {@link DomainTableCreateActionProps} + * @returns The create action element. + */ +function CreateAction({ render }: DomainTableCreateActionProps) { + const { model, props } = useDomainTableContext(); + const { t } = useTranslator('domain_management', props.customMessages); + + const label = t('domain_table.header.create_button_text'); + const canCreate = model.permissions.canCreateDomain; + const disabled = + Boolean(props.createAction?.disabled) || + Boolean(props.readOnly) || + model.isFetching || + !canCreate; + const onClick = () => model.handleCreateClick(); + + if (render) { + // The host element owns its own label/content; we only wire behavior. + // `mergeRenderProp` returns null for an invalid `render` (Fragment/string/ + // array); fall through to the default button in that case. + const merged = mergeRenderProp(render, { type: 'button', disabled, onClick }); + if (merged) return merged; + } + + return ( + + + + ); +} + +CreateAction.displayName = 'DomainTable.CreateAction'; + +/** Props for {@link DomainTableHeader}. */ +export interface DomainTableHeaderProps { + /** Action node rendered in the header's action region. Defaults to {@link CreateAction}. */ + action?: React.ReactNode; + className?: string; +} + +/** + * Title/description region. Owns the header copy; the action region defaults to + * {@link CreateAction} but can be replaced by the host. + * @param props - {@link DomainTableHeaderProps} + * @returns The header element. + */ +function DomainTableHeader({ action, className }: DomainTableHeaderProps) { + const { props } = useDomainTableContext(); + const { t } = useTranslator('domain_management', props.customMessages); + + return ( +
+
} + /> +
+ ); +} + +DomainTableHeader.displayName = 'DomainTable.Header'; + +/** Props for {@link Refresh}. */ +export interface DomainTableRefreshProps { + className?: string; +} + +/** + * The last-updated / manual-refresh control. Movable — hosts place it before or + * after {@link Content} in a structural layout. + * @param props - {@link DomainTableRefreshProps} + * @returns The refresh control. + */ +function Refresh({ className }: DomainTableRefreshProps) { + const { model, props } = useDomainTableContext(); + return ( +
+ +
+ ); +} + +Refresh.displayName = 'DomainTable.Refresh'; + +/** + * The table body (data table + row actions + modals). Reuses the existing view + * with its header and refresh regions suppressed, since those are owned by the + * {@link DomainTableHeader} and {@link Refresh} parts in composition. + * @returns The table content. + */ +function Content() { + const { model, props } = useDomainTableContext(); + parts.useRegisterPart('Content'); + return ( + + ); +} + +Content.displayName = 'DomainTable.Content'; + +/** + * The default anatomy: header (with create action) → refresh → content. Wrapping + * in `Root` + `DefaultLayout` reproduces the Tier-1 visual output exactly, so + * hosts can opt into composition incrementally. + * @param props - Optional narrow-replacement slot for the create action. + * @param props.children - When provided, replaces the default create action in the header. + * @returns The default layout subtree. + */ +function DefaultLayout({ children }: { children?: React.ReactNode }) { + const { props } = useDomainTableContext(); + return ( + <> + {!props.hideHeader && } + + + + ); +} + +DefaultLayout.displayName = 'DomainTable.DefaultLayout'; + +/** + * Organization domains table with progressive composability. + * + * Callable directly for the Tier-1 default (``), + * and exposes compound parts for narrow (Tier 2) and structural (Tier 3) + * composition. For fully headless (Tier 4) usage, see `useDomainTableModel`. + * + * @example Tier 2 — replace the create button + * ```tsx + * + * + * Add domain} /> + * + * + * ``` + * + * @example Tier 3 — structural layout with host UI interleaved + * ```tsx + * + * } />} /> + * + * + * + * + * ``` + */ +const DomainTable = Object.assign(DomainTableDefault, { + Root, + DefaultLayout, + Header: DomainTableHeader, + CreateAction, + Refresh, + Content, +}); + +export { + DomainTable, + Root, + DefaultLayout, + DomainTableHeader as Header, + CreateAction, + Refresh, + Content, + useDomainTableContext, +}; diff --git a/packages/react/src/components/auth0/my-organization/domain-table.tsx b/packages/react/src/components/auth0/my-organization/domain-table.tsx index 2b65fec79..1ee354d2d 100644 --- a/packages/react/src/components/auth0/my-organization/domain-table.tsx +++ b/packages/react/src/components/auth0/my-organization/domain-table.tsx @@ -91,6 +91,7 @@ function DomainTableView({ styling, readOnly, hideHeader, + hideRefresh, customMessages, createAction, onOpenProvider, @@ -211,18 +212,23 @@ function DomainTableView({

)} -
- -
+ {!hideRefresh && ( +
+ +
+ )} `). + * + * Tiers: + * - Tier 1 (default): `` + * - Tier 3 (structural): compose `Header` / `Content` freely, interleaving host UI. + * - Tier 4 (headless): `useOrganizationDetailsEditModel(options)` — see index re-export. + * + * HURDLE — no Tier-2 header render-prop for the primary actions: + * Unlike the SsoProviderTable pilot (whose create action lives in the header and + * is therefore host-replaceable via a `render` prop), this component's primary + * Save/Cancel actions are NOT in the header. They live inside the nested + * `OrganizationDetails` form and are driven by `model.formActions` + * (`nextAction` = Save, `previousAction` = Cancel). There is no header action + * slot to host-replace them, so a Tier-2 narrow replacement of Save/Cancel is + * not available via a header render-prop. A host that needs custom submit UI + * drops to Tier 4: the model hook exposes `formActions` (plus `updateOrgDetails`, + * loading flags, and `organization`) so the host can build and wire its own + * controls. The `Header` part here therefore has no action region by default. + * + * @module organization-details-edit.composable + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import * as React from 'react'; + +import { + OrganizationDetailsEdit as OrganizationDetailsEditDefault, + OrganizationDetailsEditView, +} from '@/components/auth0/my-organization/organization-details-edit'; +import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper'; +import { Header } from '@/components/auth0/shared/header'; +import { StyledScope } from '@/components/auth0/shared/styled-scope'; +import { useOrganizationDetailsEdit } from '@/hooks/my-organization/use-organization-details-edit'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { createComponentContext } from '@/lib/composability'; +import { cn } from '@/lib/utils'; +import type { + OrganizationDetailsEditProps, + UseOrganizationDetailsEditResult, +} from '@/types/my-organization/organization-management/organization-details-edit-types'; + +/** Value shared from `Root` to every compound part. */ +interface OrganizationDetailsEditComposition { + model: UseOrganizationDetailsEditResult; + props: OrganizationDetailsEditProps; +} + +const [OrganizationDetailsEditContext, useOrganizationDetailsEditContext, parts] = + createComponentContext('OrganizationDetailsEdit', { + requiredParts: ['Content'], + }); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** + * Stable default for `customMessages`. Hoisted to module scope so an omitted + * prop yields the same reference every render (an inline `{}` would allocate a + * fresh object each render and defeat the composition memo + translator memo). + */ +const EMPTY_CUSTOM_MESSAGES: NonNullable = {}; + +/** Props for {@link Root}. Mirrors {@link OrganizationDetailsEditProps} plus children. */ +export interface OrganizationDetailsEditRootProps extends OrganizationDetailsEditProps { + children?: React.ReactNode; +} + +/** + * Composition boundary. Runs the model hook once and shares it with all + * compound parts, then wraps children in the themed scope + loading gate. + * @param props - {@link OrganizationDetailsEditRootProps} + * @returns The provider-wrapped subtree. + */ +function Root({ + children, + saveAction, + cancelAction, + readOnly = false, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, + schema, + hideHeader, + isLoading, + backButton, +}: OrganizationDetailsEditRootProps) { + useTelemetry('organization-details'); + + const model = useOrganizationDetailsEdit({ + saveAction, + cancelAction, + readOnly, + customMessages, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + // Key the composition on concrete prop fields, not the `props` container: a + // rest-spread (`{ ...props }`) allocates a new object every render and would + // defeat this memo. With `model` now memoized in the hook, the context value + // is stable across renders unless a real input changes. + const composition = React.useMemo( + () => ({ + model, + props: { + styling, + customMessages, + schema, + readOnly, + hideHeader, + isLoading, + saveAction, + cancelAction, + backButton, + }, + }), + [ + model, + styling, + customMessages, + schema, + readOnly, + hideHeader, + isLoading, + saveAction, + cancelAction, + backButton, + ], + ); + + return ( + + + + {children} + + + + ); +} + +Root.displayName = 'OrganizationDetailsEdit.Root'; + +/** Props for {@link OrganizationDetailsEditHeader}. */ +export interface OrganizationDetailsEditHeaderProps { + className?: string; +} + +/** + * Title / back-button region. Mirrors the header block of the Tier-1 view: the + * title is derived from the organization name and the back button (when the host + * supplied one) gets its localized label. There is no action region — see the + * module doc HURDLE note on why Save/Cancel are not host-replaceable here. + * @param props - {@link OrganizationDetailsEditHeaderProps} + * @returns The header element. + */ +function OrganizationDetailsEditHeader({ className }: OrganizationDetailsEditHeaderProps) { + const { model, props } = useOrganizationDetailsEditContext(); + const { t } = useTranslator( + 'organization_management.organization_details_edit', + props.customMessages, + ); + + const organizationName = model.organization.display_name || model.organization.name || ''; + + return ( + // `mb-8` mirrors the Tier-1 view's header wrapper so the default anatomy has + // identical header→content spacing; `cn` lets a host override it via className. +
+
+
+ ); +} + +OrganizationDetailsEditHeader.displayName = 'OrganizationDetailsEdit.Header'; + +/** + * The form body (organization details form + third-party access). Reuses the + * existing view with its header suppressed, since the header is owned by the + * {@link OrganizationDetailsEditHeader} part in composition. + * @returns The form content. + */ +function Content() { + const { model, props } = useOrganizationDetailsEditContext(); + parts.useRegisterPart('Content'); + return ( + + ); +} + +Content.displayName = 'OrganizationDetailsEdit.Content'; + +/** + * The default anatomy: header → content. Wrapping in `Root` + `DefaultLayout` + * reproduces the Tier-1 visual output, so hosts can opt into composition + * incrementally. + * @returns The default layout subtree. + */ +function DefaultLayout() { + const { props } = useOrganizationDetailsEditContext(); + return ( + <> + {!props.hideHeader && } + + + ); +} + +DefaultLayout.displayName = 'OrganizationDetailsEdit.DefaultLayout'; + +/** + * Organization details edit form with progressive composability. + * + * Callable directly for the Tier-1 default (``), + * and exposes compound parts for structural (Tier 3) composition. For fully + * headless (Tier 4) usage, see `useOrganizationDetailsEditModel`. + * + * @example Tier 3 — structural layout with host UI interleaved + * ```tsx + * + * + * + * + * + * ``` + */ +const OrganizationDetailsEdit = Object.assign(OrganizationDetailsEditDefault, { + Root, + DefaultLayout, + Header: OrganizationDetailsEditHeader, + Content, +}); + +export { + OrganizationDetailsEdit, + Root, + DefaultLayout, + OrganizationDetailsEditHeader as Header, + Content, + useOrganizationDetailsEditContext, +}; diff --git a/packages/react/src/components/auth0/my-organization/organization-member-detail.composable.tsx b/packages/react/src/components/auth0/my-organization/organization-member-detail.composable.tsx new file mode 100644 index 000000000..2475ec337 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/organization-member-detail.composable.tsx @@ -0,0 +1,265 @@ +/** + * Progressive-composability layer for {@link OrganizationMemberDetail}. + * + * Adds compound sub-components (`Root`, `DefaultLayout`, `Header`, `Content`) on + * top of the existing container/view split, with zero breaking changes to the + * Tier-1 default usage (``). + * + * Tiers: + * - Tier 1 (default): `` + * - Tier 3 (structural): compose `Header` / `Content` freely, sharing one model + * via context, so hosts can interleave their own UI around the detail. + * - Tier 4 (headless): `useOrganizationMemberDetailModel(options)` — see index re-export. + * + * HURDLE — why there is no Tier-2 action part and no `Refresh` part: + * Unlike single-action tables, this component's primary actions + * (remove-from-organization, assign-roles, remove-roles) are buried inside the + * tab subcomponents and are triggered via the model's `openModal`, not via + * header buttons — they cannot be cleanly decomposed into Tier-2 render-prop + * parts. There is also no refresh/last-updated affordance, so no `Refresh` part + * and no `hideRefresh`. Hosts needing finer-grained control use the Tier-4 hook. + * + * The `Header` here is NOT the shared title/description {@link Header}: it wraps + * the component's own name-driven avatar/back-button header + * ({@link OrganizationMemberDetailHeader}), which the base view now renders + * behind a `hideHeader` gate so composition can own it as a standalone part. + * + * @module organization-member-detail.composable + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import * as React from 'react'; + +import { + OrganizationMemberDetail as OrganizationMemberDetailDefault, + OrganizationMemberDetailHeader, + OrganizationMemberDetailView, +} from '@/components/auth0/my-organization/organization-member-detail'; +import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper'; +import { StyledScope } from '@/components/auth0/shared/styled-scope'; +import { useOrganizationMemberDetail } from '@/hooks/my-organization/use-member-detail'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { createComponentContext } from '@/lib/composability'; +import type { + OrganizationMemberDetailProps, + UseOrganizationMemberDetailResult, +} from '@/types/my-organization/member-management/organization-member-detail-types'; + +/** Value shared from `Root` to every compound part. */ +interface OrganizationMemberDetailComposition { + model: UseOrganizationMemberDetailResult; + props: OrganizationMemberDetailProps; +} + +const [OrganizationMemberDetailContext, useOrganizationMemberDetailContext, parts] = + createComponentContext('OrganizationMemberDetail', { + requiredParts: ['Content'], + }); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** + * Stable default for `customMessages`. Hoisted to module scope so an omitted + * prop yields the same reference every render (an inline `{}` would allocate a + * fresh object each render and defeat the composition memo + translator memo). + */ +const EMPTY_CUSTOM_MESSAGES: NonNullable = {}; + +/** Props for {@link Root}. Mirrors {@link OrganizationMemberDetailProps} plus children. */ +export interface OrganizationMemberDetailRootProps extends OrganizationMemberDetailProps { + children?: React.ReactNode; +} + +/** + * Composition boundary. Runs the model hook once and shares it with all + * compound parts, then wraps children in the themed scope + loading gate. + * + * Mirrors the Tier-1 container exactly: the same props are destructured, the + * same `useOrganizationMemberDetail(...)` options object is passed, and the same + * `GateKeeper isLoading={model.isLoading}` gate is applied. + * @param props - {@link OrganizationMemberDetailRootProps} + * @returns The provider-wrapped subtree. + */ +function Root({ + children, + userId, + onBack, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, + schema, + readOnly, + hideHeader, + initialTab, + removeFromOrganizationAction, + assignRolesAction, + removeRolesAction, +}: OrganizationMemberDetailRootProps) { + const model = useOrganizationMemberDetail({ + userId, + onBack, + customMessages, + initialTab, + removeFromOrganizationAction, + assignRolesAction, + removeRolesAction, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + // Key the composition on concrete prop fields, not the `props` container: a + // rest-spread (`{ ...props }`) allocates a new object every render and would + // defeat this memo. With `model` now memoized in the hook, the context value + // is stable across renders unless a real input changes. + const composition = React.useMemo( + () => ({ + model, + props: { + styling, + customMessages, + schema, + readOnly, + userId, + onBack, + hideHeader, + initialTab, + removeFromOrganizationAction, + assignRolesAction, + removeRolesAction, + }, + }), + [ + model, + styling, + customMessages, + schema, + readOnly, + userId, + onBack, + hideHeader, + initialTab, + removeFromOrganizationAction, + assignRolesAction, + removeRolesAction, + ], + ); + + return ( + + + + {children} + + + + ); +} + +Root.displayName = 'OrganizationMemberDetail.Root'; + +/** + * The member's avatar/back-button header (name + user-id badge). Wraps the base + * {@link OrganizationMemberDetailHeader}, driven entirely by the shared model, so + * Tier-3 hosts can position it independently of the tabs body. + * + * This is NOT the shared title/description header used by table components — + * it is this component's own data-driven header, so it takes no `action`/copy + * props. + * @returns The member detail header element. + */ +function Header() { + const { model, props } = useOrganizationMemberDetailContext(); + return ( + + ); +} + +Header.displayName = 'OrganizationMemberDetail.Header'; + +/** + * The member detail body (tabs + modals). Reuses the existing view with its + * built-in header suppressed (`hideHeader`), since the header is owned by the + * {@link Header} part in composition. Prop pass-through otherwise mirrors the + * Tier-1 container exactly (spread of the shared model plus `styling` and + * `customMessages`). + * @returns The member detail content. + */ +function Content() { + const { model, props } = useOrganizationMemberDetailContext(); + parts.useRegisterPart('Content'); + return ( + + ); +} + +Content.displayName = 'OrganizationMemberDetail.Content'; + +/** + * The default anatomy: header → content. Wrapping in `Root` + `DefaultLayout` + * reproduces the Tier-1 visual output exactly, so hosts can opt into composition + * incrementally. `hideHeader` suppresses the header, mirroring the Tier-1 prop. + * @returns The default layout subtree. + */ +function DefaultLayout() { + const { props } = useOrganizationMemberDetailContext(); + return ( + <> + {!props.hideHeader &&
} + + + ); +} + +DefaultLayout.displayName = 'OrganizationMemberDetail.DefaultLayout'; + +/** + * Organization member detail with progressive composability. + * + * Callable directly for the Tier-1 default (``), + * and exposes `Root`/`Header`/`Content` for structural (Tier 3) composition — + * interleaving host UI around the detail. For fully headless (Tier 4) usage, see + * `useOrganizationMemberDetailModel`. + * + * There is no header action region and no refresh affordance, so it offers no + * Tier-2 render-prop parts (see the module HURDLE note). + * + * @example Tier 3 — structural layout with host UI interleaved + * ```tsx + * + * + * + * + * + * ``` + */ +const OrganizationMemberDetail = Object.assign(OrganizationMemberDetailDefault, { + Root, + DefaultLayout, + Header, + Content, +}); + +export { + OrganizationMemberDetail, + Root, + DefaultLayout, + Header, + Content, + useOrganizationMemberDetailContext, +}; diff --git a/packages/react/src/components/auth0/my-organization/organization-member-detail.tsx b/packages/react/src/components/auth0/my-organization/organization-member-detail.tsx index df6ba5470..dd47c6f14 100644 --- a/packages/react/src/components/auth0/my-organization/organization-member-detail.tsx +++ b/packages/react/src/components/auth0/my-organization/organization-member-detail.tsx @@ -29,11 +29,15 @@ import type { } from '@/types/my-organization/member-management/organization-member-detail-types'; /** - * Member detail header component. + * Member detail header component: back button + avatar + name/user-id badge. + * + * Exported so the composability layer can render it as a standalone + * `OrganizationMemberDetail.Header` part (Tier-3). The view still renders it + * inline by default; composition suppresses that copy via `hideHeader`. * @param props - Component props containing state and handlers * @returns The rendered header element */ -function Header({ +export function OrganizationMemberDetailHeader({ member, styling, customMessages, @@ -100,6 +104,7 @@ export function OrganizationMemberDetailView( closeModal, openModal, readOnly, + hideHeader, handleRemoveFromOrganizationConfirm, } = props; @@ -154,12 +159,14 @@ export function OrganizationMemberDetailView( return (
-
+ {!hideHeader && ( + + )} setActiveTab(value as 'details' | 'roles')} @@ -247,6 +254,7 @@ export function OrganizationMemberDetail(props: OrganizationMemberDetailProps) { customMessages = {}, styling = { variables: { common: {}, light: {}, dark: {} }, classes: {} }, readOnly = false, + hideHeader = false, initialTab, removeFromOrganizationAction, assignRolesAction, @@ -271,6 +279,7 @@ export function OrganizationMemberDetail(props: OrganizationMemberDetailProps) { styling={styling} readOnly={readOnly} customMessages={customMessages} + hideHeader={hideHeader} /> ); diff --git a/packages/react/src/components/auth0/my-organization/organization-member-management.composable.tsx b/packages/react/src/components/auth0/my-organization/organization-member-management.composable.tsx new file mode 100644 index 000000000..782e257e8 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/organization-member-management.composable.tsx @@ -0,0 +1,393 @@ +/** + * Progressive-composability layer for {@link OrganizationMemberManagement}. + * + * Adds compound sub-components (`Root`, `DefaultLayout`, `Header`, + * `InviteAction`, `Refresh`, `Content`) on top of the existing container/view + * split, with zero breaking changes to the Tier-1 default usage + * (``). + * + * Tiers: + * - Tier 1 (default): `` + * - Tier 2 (narrow): `` + * `} />` ... + * - Tier 3 (structural): compose `Header` / `Refresh` / `Content` freely, interleaving host UI. + * - Tier 4 (headless): `useOrganizationMemberManagementModel(options)` — see index re-export. + * + * @module organization-member-management.composable + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import { Plus } from 'lucide-react'; +import * as React from 'react'; + +import { + OrganizationMemberManagement as OrganizationMemberManagementDefault, + OrganizationMemberManagementView, +} from '@/components/auth0/my-organization/organization-member-management'; +import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper'; +import { Header } from '@/components/auth0/shared/header'; +import { PermissionDeniedTooltip } from '@/components/auth0/shared/permission-denied-tooltip'; +import { RefreshIndicator } from '@/components/auth0/shared/refresh-indicator'; +import { StyledScope } from '@/components/auth0/shared/styled-scope'; +import { Button } from '@/components/ui/button'; +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 { createComponentContext, mergeRenderProp } from '@/lib/composability'; +import { cn } from '@/lib/utils'; +import type { + OrganizationMemberManagementProps, + UseOrganizationMemberManagementResult, +} from '@/types/my-organization/member-management/organization-member-management-types'; + +/** Value shared from `Root` to every compound part. */ +interface OrganizationMemberManagementComposition { + model: UseOrganizationMemberManagementResult; + props: OrganizationMemberManagementProps; +} + +const [OrganizationMemberManagementContext, useOrganizationMemberManagementContext, parts] = + createComponentContext('OrganizationMemberManagement', { + requiredParts: ['Content'], + }); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** + * Stable default for `customMessages`. Hoisted to module scope so an omitted + * prop yields the same reference every render (an inline `{}` would allocate a + * fresh object each render and defeat the composition memo + translator memo). + */ +const EMPTY_CUSTOM_MESSAGES: NonNullable = {}; + +/** Props for {@link Root}. Mirrors {@link OrganizationMemberManagementProps} plus children. */ +export interface OrganizationMemberManagementRootProps extends OrganizationMemberManagementProps { + children?: React.ReactNode; +} + +/** + * Composition boundary. Runs the model hook once and shares it with all + * compound parts, then wraps children in the themed scope + loading gate. + * @param props - {@link OrganizationMemberManagementRootProps} + * @returns The provider-wrapped subtree. + */ +function Root({ + children, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, + readOnly = false, + schema, + hideHeader, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + viewMemberDetailsAction, + assignRolesAction, + removeFromOrganizationAction, +}: OrganizationMemberManagementRootProps) { + const model = useOrganizationMemberManagement({ + customMessages, + readOnly, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + viewMemberDetailsAction, + assignRolesAction, + removeFromOrganizationAction, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + // Key the composition on concrete prop fields, not the `props` container: a + // rest-spread (`{ ...props }`) allocates a new object every render and would + // defeat this memo. With `model` now memoized in the hook, the context value + // is stable across renders unless a real input changes. + const composition = React.useMemo( + () => ({ + model, + props: { + styling, + customMessages, + schema, + readOnly, + hideHeader, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + viewMemberDetailsAction, + assignRolesAction, + removeFromOrganizationAction, + }, + }), + [ + model, + styling, + customMessages, + schema, + readOnly, + hideHeader, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + viewMemberDetailsAction, + assignRolesAction, + removeFromOrganizationAction, + ], + ); + + return ( + + + + {children} + + + + ); +} + +Root.displayName = 'OrganizationMemberManagement.Root'; + +/** Props for {@link InviteAction}. */ +export interface OrganizationMemberManagementInviteActionProps { + /** Host element to render in place of the default UIC button. */ + render?: React.ReactElement; +} + +/** + * The invite-member trigger. Renders the default UIC button, or a host-supplied + * element via `render`, wired to the model's create-invitation command. Follows + * the same rule as the Tier-1 header: omitted entirely when `readOnly`. + * @param props - {@link OrganizationMemberManagementInviteActionProps} + * @returns The invite action element, or `null` when read-only. + */ +function InviteAction({ render }: OrganizationMemberManagementInviteActionProps) { + const { model, props } = useOrganizationMemberManagementContext(); + const { t } = useTranslator('member_management', props.customMessages); + + if (props.readOnly) { + return null; + } + + const label = t('invite_button'); + const canInvite = model.permissions.canInvite; + const disabled = + Boolean(props.readOnly) || model.isLoadingConnections || !canInvite || model.hasNoConnections; + const onClick = () => model.openModal({ type: 'create' }); + + if (render) { + // The host element owns its own label/content; we only wire behavior. + // `mergeRenderProp` returns null for an invalid `render` (Fragment/string/ + // array); fall through to the default button in that case. + const merged = mergeRenderProp(render, { type: 'button', disabled, onClick }); + if (merged) return merged; + } + + return ( + + + + ); +} + +InviteAction.displayName = 'OrganizationMemberManagement.InviteAction'; + +/** Props for {@link OrganizationMemberManagementHeader}. */ +export interface OrganizationMemberManagementHeaderProps { + /** Action node rendered in the header's action region. Defaults to {@link InviteAction}. */ + action?: React.ReactNode; + className?: string; +} + +/** + * Title/description region. Owns the header copy; the action region defaults to + * {@link InviteAction} but can be replaced by the host. + * @param props - {@link OrganizationMemberManagementHeaderProps} + * @returns The header element. + */ +function OrganizationMemberManagementHeader({ + action, + className, +}: OrganizationMemberManagementHeaderProps) { + const { props } = useOrganizationMemberManagementContext(); + const { t } = useTranslator('member_management', props.customMessages); + + return ( +
+
} + /> +
+ ); +} + +OrganizationMemberManagementHeader.displayName = 'OrganizationMemberManagement.Header'; + +/** Props for {@link Refresh}. */ +export interface OrganizationMemberManagementRefreshProps { + className?: string; +} + +/** + * The last-updated / manual-refresh control. Tab-dependent: reflects members or + * invitations depending on the active tab. + * + * NOTE: In the Tier-1 layout this control is visually coupled inside the Tabs + * row (next to `TabsList`). As a standalone compound part it is visually + * detached from the tabs, so Tier-3 hosts that place it outside the tab row + * should expect it to sit apart from the members/invitations toggle. + * @param props - {@link OrganizationMemberManagementRefreshProps} + * @returns The refresh control. + */ +function Refresh({ className }: OrganizationMemberManagementRefreshProps) { + const { model, props } = useOrganizationMemberManagementContext(); + + const refreshState = + model.activeTab === 'members' + ? { + isStale: model.isMembersStale, + isFetching: model.isFetchingMembers, + lastUpdatedAt: model.membersUpdatedAt || undefined, + onRefresh: model.refetchMembers, + } + : { + isStale: model.isInvitationsStale, + isFetching: model.isFetchingInvitations, + lastUpdatedAt: model.invitationsUpdatedAt || undefined, + onRefresh: model.refetchInvitations, + }; + + return ( +
+ +
+ ); +} + +Refresh.displayName = 'OrganizationMemberManagement.Refresh'; + +/** + * The tabs body (members/invitations tables + modals). Reuses the existing view + * with its header and refresh regions suppressed, since those are owned by the + * {@link OrganizationMemberManagementHeader} and {@link Refresh} parts in + * composition. + * @returns The tabs content. + */ +function Content() { + const { model, props } = useOrganizationMemberManagementContext(); + parts.useRegisterPart('Content'); + return ( + + ); +} + +Content.displayName = 'OrganizationMemberManagement.Content'; + +/** + * The default anatomy: header (with invite action) → content (with the built-in + * tab-coupled refresh). Wrapping in `Root` + `DefaultLayout` reproduces the + * Tier-1 visual output exactly, so hosts can opt into composition incrementally. + * @param props - Optional narrow-replacement slot for the invite action. + * @param props.children - When provided, replaces the default invite action in the header. + * @returns The default layout subtree. + */ +function DefaultLayout({ children }: { children?: React.ReactNode }) { + const { model, props } = useOrganizationMemberManagementContext(); + return ( + <> + {!props.hideHeader && } + + + ); +} + +DefaultLayout.displayName = 'OrganizationMemberManagement.DefaultLayout'; + +/** + * Organization member management with progressive composability. + * + * Callable directly for the Tier-1 default + * (``), and exposes compound parts + * for narrow (Tier 2) and structural (Tier 3) composition. For fully headless + * (Tier 4) usage, see `useOrganizationMemberManagementModel`. + * + * @example Tier 2 — replace the invite button + * ```tsx + * + * + * Invite} /> + * + * + * ``` + * + * @example Tier 3 — structural layout with host UI interleaved + * ```tsx + * + * + * + * + * + * + * ``` + */ +const OrganizationMemberManagement = Object.assign(OrganizationMemberManagementDefault, { + Root, + DefaultLayout, + Header: OrganizationMemberManagementHeader, + InviteAction, + Refresh, + Content, +}); + +export { + OrganizationMemberManagement, + Root, + DefaultLayout, + OrganizationMemberManagementHeader as Header, + InviteAction, + Refresh, + Content, + useOrganizationMemberManagementContext, +}; 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 index c96856413..dd74689b1 100644 --- a/packages/react/src/components/auth0/my-organization/organization-member-management.tsx +++ b/packages/react/src/components/auth0/my-organization/organization-member-management.tsx @@ -45,6 +45,7 @@ export function OrganizationMemberManagementView(props: OrganizationMemberManage styling, customMessages, hideHeader, + hideRefresh, activeTab, permissions, members, @@ -213,14 +214,16 @@ export function OrganizationMemberManagementView(props: OrganizationMemberManage {t('tabs.members')} {t('tabs.invitations')} - + {!hideRefresh && ( + + )}
diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-create.composable.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-create.composable.tsx new file mode 100644 index 000000000..ba7178088 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/sso-provider-create.composable.tsx @@ -0,0 +1,245 @@ +/** + * Progressive-composability layer for {@link SsoProviderCreate}. + * + * Adds compound sub-components (`Root`, `DefaultLayout`, `Content`) on top of + * the existing container/view split, with zero breaking changes to the Tier-1 + * default usage (``). + * + * Tiers: + * - Tier 1 (default): `` + * - Tier 3 (structural): compose `Content` freely, interleaving host UI around + * the wizard (`` shares one model with `` via context). + * - Tier 4 (headless): `useSsoProviderCreateModel(options)` — see index re-export. + * + * ## HURDLE — why this layer is headless-leaning (no action/header parts) + * + * Unlike the {@link SsoProviderTable} pilot, `SsoProviderCreate` is + * **wizard-driven**, which changes what can honestly be offered as composable + * parts: + * + * 1. **No host-replaceable action part.** The Next / Previous / Complete + * controls are owned by the shared `` component and the per-step + * `createStepActions(...)` factory — not exposed as a single, standalone + * button we could wire a host element into (as `CreateAction` does for the + * table). Navigation and submission are internal to the wizard step model, + * so there is **no Tier-2 action part** here. + * 2. **No `Header` part / no `hideHeader` — intentional exception.** The view + * always renders its own `
` (title + optional back button) and exposes + * no `hideHeader` flag, so the header cannot be lifted out or suppressed + * independently. Header copy stays inside `Content`. This is a deliberate + * exception to the header-parity other components have: the wizard owns its + * own Next/Previous chrome, so a standalone `Header` part would be a hollow + * slot with no navigation behind it — misleading rather than useful. + * 3. **No `RefreshIndicator`.** This is a creation wizard, not a data table — + * there is no stale/refetch surface, hence no `Refresh` part or `hideRefresh`. + * 4. **Flat model spread.** The view consumes the model result spread flat + * (plus `strategy` / `details` / `configure` from `formData` and the styling / + * back-button / navigation props), so `Content` mirrors the Tier-1 container's + * view render exactly. + * + * Therefore this layer offers only **structural wrapping (`Root` / `Content` / + * `DefaultLayout`)** plus the **Tier-4 model hook** — no `Header`, + * `CreateAction`, or `Refresh` parts. + * + * @module sso-provider-create.composable + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import * as React from 'react'; + +import { + SsoProviderCreate as SsoProviderCreateDefault, + SsoProviderCreateView, +} from '@/components/auth0/my-organization/sso-provider-create'; +import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper'; +import { StyledScope } from '@/components/auth0/shared/styled-scope'; +import { useSsoProviderCreate } from '@/hooks/my-organization/use-sso-provider-create'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { createComponentContext } from '@/lib/composability'; +import type { + SsoProviderCreateProps, + UseSsoProviderCreateResult, +} from '@/types/my-organization/idp-management/sso-provider/sso-provider-create-types'; + +/** Value shared from `Root` to every compound part. */ +interface SsoProviderCreateComposition { + model: UseSsoProviderCreateResult; + props: SsoProviderCreateProps; +} + +const [SsoProviderCreateContext, useSsoProviderCreateContext, parts] = + createComponentContext('SsoProviderCreate', { + requiredParts: ['Content'], + }); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** + * Stable default for `customMessages`. Hoisted to module scope so an omitted + * prop yields the same reference every render (an inline `{}` would allocate a + * fresh object each render and defeat the composition memo + translator memo). + */ +const EMPTY_CUSTOM_MESSAGES: NonNullable = {}; + +/** Props for {@link Root}. Mirrors {@link SsoProviderCreateProps} plus children. */ +export interface SsoProviderCreateRootProps extends SsoProviderCreateProps { + children?: React.ReactNode; +} + +/** + * Composition boundary. Runs the model hook once and shares it with all + * compound parts, then wraps children in the loading gate + themed scope. + * + * Mirrors the Tier-1 container exactly, including its `GateKeeper` usage — note + * the container passes **no** `isLoading` prop (the wizard manages its own + * per-step loading), so this `Root` replicates that. + * @param props - {@link SsoProviderCreateRootProps} + * @returns The provider-wrapped subtree. + */ +function Root({ + children, + createAction, + backButton, + schema, + readOnly = false, + onNext, + onPrevious, + resolveSamlMetadata, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, +}: SsoProviderCreateRootProps) { + useTelemetry('sso-create-configuration'); + + const model = useSsoProviderCreate({ + createAction, + customMessages, + onNext, + onPrevious, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + // Key the composition on concrete prop fields, not a `props` container: a + // rest-spread (`{ ...props }`) allocates a new object every render and would + // defeat this memo. With `model` now memoized in the hook, the context value + // is stable across renders unless a real input changes. + const composition = React.useMemo( + () => ({ + model, + props: { + createAction, + backButton, + schema, + readOnly, + onNext, + onPrevious, + resolveSamlMetadata, + customMessages, + styling, + }, + }), + [ + model, + createAction, + backButton, + schema, + readOnly, + onNext, + onPrevious, + resolveSamlMetadata, + customMessages, + styling, + ], + ); + + return ( + + + + {children} + + + + ); +} + +Root.displayName = 'SsoProviderCreate.Root'; + +/** + * The wizard body (header + multi-step provider creation wizard). Reuses the + * existing view, reconstructing the `{ logic, handlers }` bundles from the + * shared model + props **exactly** as the Tier-1 container does. + * + * The view always renders its own header (no `hideHeader`); see the module + * HURDLE note. + * @returns The wizard content. + */ +function Content() { + const { model, props } = useSsoProviderCreateContext(); + parts.useRegisterPart('Content'); + const { strategy, details, configure } = model.formData; + + return ( + + ); +} + +Content.displayName = 'SsoProviderCreate.Content'; + +/** + * The default anatomy: just the wizard content. Wrapping in `Root` + + * `DefaultLayout` reproduces the Tier-1 visual output exactly, so hosts can opt + * into composition incrementally. + * @returns The default layout subtree. + */ +function DefaultLayout() { + return ; +} + +DefaultLayout.displayName = 'SsoProviderCreate.DefaultLayout'; + +/** + * SSO provider creation wizard with progressive composability. + * + * Callable directly for the Tier-1 default (``), + * and exposes structural compound parts (`Root` / `Content` / `DefaultLayout`) + * for Tier-3 composition. For fully headless (Tier 4) usage, see + * `useSsoProviderCreateModel`. + * + * See the module HURDLE note for why no `Header` / action / `Refresh` parts are + * offered (wizard-owned navigation, always-on header, no refresh surface). + * + * @example Tier 3 — interleave host UI around the wizard + * ```tsx + * + * + * + * + * ``` + */ +const SsoProviderCreate = Object.assign(SsoProviderCreateDefault, { + Root, + DefaultLayout, + Content, +}); + +export { SsoProviderCreate, Root, DefaultLayout, Content, useSsoProviderCreateContext }; diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-edit.composable.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-edit.composable.tsx new file mode 100644 index 000000000..f00757138 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/sso-provider-edit.composable.tsx @@ -0,0 +1,328 @@ +/** + * Progressive-composability layer for {@link SsoProviderEdit}. + * + * Adds compound sub-components (`Root`, `DefaultLayout`, `Header`, `Content`) on + * top of the existing container/view split, with zero breaking changes to the + * Tier-1 default usage (``). + * + * Tiers: + * - Tier 1 (default): `` + * - Tier 3 (structural): compose `Header` / `Content` freely, interleaving host UI. + * - Tier 4 (headless): `useSsoProviderEditModel(providerId, options)` — see index re-export. + * + * HURDLES — why there is no Tier-2 render-prop action here (unlike the + * SsoProviderTable pilot's `CreateAction`): + * 1. The header's primary action is a SWITCH (enable/disable toggle), whose + * contract is `onCheckedChange(checked: boolean)`, not `onClick`. The shared + * {@link mergeRenderProp} helper is button/`onClick`-oriented (it chains + * `onClick` and honours `event.preventDefault()`), so it does not apply to a + * switch. No host-replaceable `ToggleAction` render-prop is offered; the + * switch is rendered by the shared {@link Header} exactly as in Tier 1. + * 2. The Save action lives INSIDE the SSO tab (`formActions.nextAction`), not in + * the header — it is not exposed as a header slot and therefore is not + * host-replaceable via composition either. + * + * Hosts needing custom enable/save controls should drop to Tier 4 + * (`useSsoProviderEditModel`) and drive `handleToggleProvider` / `updateProvider` + * from their own UI. + * + * @module sso-provider-edit.composable + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import * as React from 'react'; + +import { + SsoProviderEdit as SsoProviderEditDefault, + SsoProviderEditView, +} from '@/components/auth0/my-organization/sso-provider-edit'; +import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper'; +import { Header } from '@/components/auth0/shared/header'; +import { StyledScope } from '@/components/auth0/shared/styled-scope'; +import { useSsoProviderEdit } from '@/hooks/my-organization/use-sso-provider-edit'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { createComponentContext } from '@/lib/composability'; +import { cn } from '@/lib/utils'; +import type { + SsoProviderEditProps, + UseSsoProviderEditResult, +} from '@/types/my-organization/idp-management/sso-provider/sso-provider-edit-types'; + +/** Value shared from `Root` to every compound part. */ +interface SsoProviderEditComposition { + model: UseSsoProviderEditResult; + /** Normalized props (defaults applied) shared with every part. */ + props: SsoProviderEditProps; +} + +const [SsoProviderEditContext, useSsoProviderEditContext, parts] = + createComponentContext('SsoProviderEdit', { + requiredParts: ['Content'], + }); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** + * Stable default for `customMessages`. Hoisted to module scope so an omitted + * prop yields the same reference every render (an inline `{}` would allocate a + * fresh object each render and defeat the composition memo + translator memo). + */ +const EMPTY_CUSTOM_MESSAGES: NonNullable = {}; + +/** Props for {@link Root}. Mirrors {@link SsoProviderEditProps} plus children. */ +export interface SsoProviderEditRootProps extends SsoProviderEditProps { + children?: React.ReactNode; +} + +/** + * Composition boundary. Runs the model hook once and shares it with all + * compound parts, then wraps children in the themed scope + loading gate. + * @param props - {@link SsoProviderEditRootProps} + * @returns The provider-wrapped subtree. + */ +function Root({ + children, + providerId, + sso, + provisioning, + domains, + backButton, + schema, + hideHeader = false, + hideProvisioningTab = false, + hideDeleteProvider = false, + hideRemoveFromOrganization = false, + hideAttributeMappings = false, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, + readOnly = false, + enableProviderAction, +}: SsoProviderEditRootProps) { + useTelemetry('sso-edit-configuration'); + + const model = useSsoProviderEdit(providerId, { + sso, + provisioning, + domains, + customMessages, + skipProvisioningFetch: hideProvisioningTab && hideAttributeMappings, + enableProviderAction, + }); + + const isLoading = model.isLoading || model.isLoadingConfig || model.isLoadingIdpConfig; + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + // Key the composition on concrete prop fields, not a `props` container: a + // rest-spread (`{ ...props }`) allocates a new object every render and would + // defeat this memo. With `model` now memoized in the hook, the context value + // is stable across renders unless a real input changes. + const composition = React.useMemo( + () => ({ + model, + props: { + providerId, + sso, + provisioning, + domains, + backButton, + schema, + styling, + readOnly, + customMessages, + hideHeader, + hideProvisioningTab, + hideDeleteProvider, + hideRemoveFromOrganization, + hideAttributeMappings, + enableProviderAction, + }, + }), + [ + model, + providerId, + sso, + provisioning, + domains, + backButton, + schema, + styling, + readOnly, + customMessages, + hideHeader, + hideProvisioningTab, + hideDeleteProvider, + hideRemoveFromOrganization, + hideAttributeMappings, + enableProviderAction, + ], + ); + + return ( + + + + {children} + + + + ); +} + +Root.displayName = 'SsoProviderEdit.Root'; + +/** Props for {@link SsoProviderEditHeader}. */ +export interface SsoProviderEditHeaderProps { + className?: string; +} + +/** + * Title / back-button / enable-toggle region. Mirrors the view's header block. + * The single action is a SWITCH (enable/disable toggle) rendered by the shared + * {@link Header}; it is not host-replaceable (see module HURDLES). + * @param props - {@link SsoProviderEditHeaderProps} + * @returns The header element. + */ +function SsoProviderEditHeader({ className }: SsoProviderEditHeaderProps) { + const { model, props } = useSsoProviderEditContext(); + const { t } = useTranslator('idp_management.edit_sso_provider', props.customMessages); + const { t: tCommon } = useTranslator('common', props.customMessages?.common); + + // Reused as both the switch's accessible name and its tooltip, so the toggle + // is never an unlabeled control for assistive tech. + const toggleLabel = model.provider?.is_enabled + ? t('header.disable_provider_tooltip_text') + : t('header.enable_provider_tooltip_text'); + + return ( +
+ ); +} + +SsoProviderEditHeader.displayName = 'SsoProviderEdit.Header'; + +/** + * The tabbed editor body (SSO / provisioning / domains tabs + modals). Reuses + * the existing view with its header suppressed, since the header is owned by the + * {@link SsoProviderEditHeader} part in composition. Replicates the container's + * exact prop pass-through (see `sso-provider-edit.tsx` view render). + * @returns The editor content. + */ +function Content() { + const { model, props } = useSsoProviderEditContext(); + parts.useRegisterPart('Content'); + return ( + + ); +} + +Content.displayName = 'SsoProviderEdit.Content'; + +/** + * The default anatomy: header (with enable toggle) → content. Wrapping in + * `Root` + `DefaultLayout` reproduces the Tier-1 visual output, so hosts can + * opt into composition incrementally. + * @returns The default layout subtree. + */ +function DefaultLayout() { + const { props } = useSsoProviderEditContext(); + return ( + <> + {!props.hideHeader && } + + + ); +} + +DefaultLayout.displayName = 'SsoProviderEdit.DefaultLayout'; + +/** + * SSO provider edit interface with progressive composability. + * + * Callable directly for the Tier-1 default (``), + * and exposes compound parts (`Root`, `DefaultLayout`, `Header`, `Content`) for + * structural (Tier 3) composition. For fully headless (Tier 4) usage, see + * `useSsoProviderEditModel`. + * + * The header's enable/disable toggle is a switch and the save action lives in + * the SSO tab; neither is host-replaceable via a render prop (see module + * HURDLES). Hosts needing custom controls use Tier 4. + * + * @example Tier 3 — structural layout with host UI interleaved + * ```tsx + * + * + * + * + * + * ``` + */ +const SsoProviderEdit = Object.assign(SsoProviderEditDefault, { + Root, + DefaultLayout, + Header: SsoProviderEditHeader, + Content, +}); + +export { + SsoProviderEdit, + Root, + DefaultLayout, + SsoProviderEditHeader as Header, + Content, + useSsoProviderEditContext, +}; diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx index 7e63cf93d..1b57974bd 100644 --- a/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx +++ b/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx @@ -171,6 +171,12 @@ function SsoProviderEditView(props: SsoProviderEditViewProps) { [styling, isDarkMode], ); + // Reused as both the switch's accessible name and its tooltip, so the toggle + // is never an unlabeled control for assistive tech. + const toggleLabel = provider?.is_enabled + ? t('header.disable_provider_tooltip_text') + : t('header.enable_provider_tooltip_text'); + return (
@@ -187,6 +193,7 @@ function SsoProviderEditView(props: SsoProviderEditViewProps) { actions={[ { type: 'switch', + 'aria-label': toggleLabel, checked: provider?.is_enabled ?? false, onCheckedChange: handleToggleProvider, hidden: readOnly, @@ -198,9 +205,7 @@ function SsoProviderEditView(props: SsoProviderEditViewProps) { tooltip: { content: !permissions.canUpdateProvider ? tCommon('error.forbidden') - : provider?.is_enabled - ? t('header.disable_provider_tooltip_text') - : t('header.enable_provider_tooltip_text'), + : toggleLabel, }, }, ]} diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-table.composable.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-table.composable.tsx new file mode 100644 index 000000000..c4832d804 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/sso-provider-table.composable.tsx @@ -0,0 +1,372 @@ +/** + * Progressive-composability layer for {@link SsoProviderTable}. + * + * Adds compound sub-components (`Root`, `DefaultLayout`, `Header`, + * `CreateAction`, `Refresh`, `Content`) on top of the existing container/view + * split, with zero breaking changes to the Tier-1 default usage + * (``). + * + * Tiers: + * - Tier 1 (default): `` + * - Tier 2 (narrow): `` + * `} />` ... + * - Tier 3 (structural): compose `Header` / `Refresh` / `Content` freely, interleaving host UI. + * - Tier 4 (headless): `useSsoProviderTableModel(options)` — see index re-export. + * + * @module sso-provider-table.composable + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import { Plus } from 'lucide-react'; +import * as React from 'react'; + +import { + SsoProviderTable as SsoProviderTableDefault, + SsoProviderTableView, +} from '@/components/auth0/my-organization/sso-provider-table'; +import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper'; +import { Header } from '@/components/auth0/shared/header'; +import { PermissionDeniedTooltip } from '@/components/auth0/shared/permission-denied-tooltip'; +import { RefreshIndicator } from '@/components/auth0/shared/refresh-indicator'; +import { StyledScope } from '@/components/auth0/shared/styled-scope'; +import { Button } from '@/components/ui/button'; +import { useSsoProviderTable } from '@/hooks/my-organization/use-sso-provider-table'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import { createComponentContext, mergeRenderProp } from '@/lib/composability'; +import { cn } from '@/lib/utils'; +import type { + SsoProviderTableProps, + UseSsoProviderTableResult, +} from '@/types/my-organization/idp-management/sso-provider/sso-provider-table-types'; + +/** Value shared from `Root` to every compound part. */ +interface SsoProviderTableComposition { + model: UseSsoProviderTableResult; + props: SsoProviderTableProps; +} + +const [SsoProviderTableContext, useSsoProviderTableContext, parts] = + createComponentContext('SsoProviderTable', { + requiredParts: ['Content'], + }); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** + * Stable default for `customMessages`. Hoisted to module scope so an omitted + * prop yields the same reference every render (an inline `{}` would allocate a + * fresh object each render and defeat the composition memo + translator memo). + */ +const EMPTY_CUSTOM_MESSAGES: NonNullable = {}; + +/** Props for {@link Root}. Mirrors {@link SsoProviderTableProps} plus children. */ +export interface SsoProviderTableRootProps extends SsoProviderTableProps { + children?: React.ReactNode; +} + +/** + * Composition boundary. Runs the model hook once and shares it with all + * compound parts, then wraps children in the themed scope + loading gate. + * @param props - {@link SsoProviderTableRootProps} + * @returns The provider-wrapped subtree. + */ +function Root({ + children, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, + readOnly = false, + hideHeader, + hideDeleteProvider, + hideRemoveFromOrganization, + schema, + createAction, + editAction, + deleteAction, + deleteFromOrganizationAction, + enableProviderAction, +}: SsoProviderTableRootProps) { + useTelemetry('sso-table-configuration'); + + const model = useSsoProviderTable({ + readOnly, + customMessages, + createAction, + editAction, + deleteAction, + deleteFromOrganizationAction, + enableProviderAction, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + // Key the composition on concrete prop fields, not the `props` container: a + // rest-spread (`{ ...props }`) allocates a new object every render and would + // defeat this memo. With `model` now memoized in the hook, the context value + // is stable across renders unless a real input changes. + const composition = React.useMemo( + () => ({ + model, + props: { + styling, + customMessages, + schema, + readOnly, + hideHeader, + hideDeleteProvider, + hideRemoveFromOrganization, + createAction, + editAction, + deleteAction, + deleteFromOrganizationAction, + enableProviderAction, + }, + }), + [ + model, + styling, + customMessages, + schema, + readOnly, + hideHeader, + hideDeleteProvider, + hideRemoveFromOrganization, + createAction, + editAction, + deleteAction, + deleteFromOrganizationAction, + enableProviderAction, + ], + ); + + return ( + + + + {children} + + + + ); +} + +Root.displayName = 'SsoProviderTable.Root'; + +/** Props for {@link CreateAction}. */ +export interface SsoProviderTableCreateActionProps { + /** Host element to render in place of the default UIC button. */ + render?: React.ReactElement; +} + +/** + * The create-provider trigger. Renders the default UIC button, or a + * host-supplied element via `render`, wired to the model's create command. + * Hidden/disabled state follows the same rules as the Tier-1 header button. + * @param props - {@link SsoProviderTableCreateActionProps} + * @returns The create action element, or `null` when create is hidden. + */ +function CreateAction({ render }: SsoProviderTableCreateActionProps) { + const { model, props } = useSsoProviderTableContext(); + const { t } = useTranslator('idp_management.sso_provider_table', props.customMessages); + + if (model.shouldHideCreate || model.isViewLoading) { + return null; + } + + const label = t('header.create_button_text'); + const canCreate = model.permissions.canCreateProvider; + const disabled = Boolean(props.createAction?.disabled) || Boolean(props.readOnly) || !canCreate; + const onClick = () => model.handleCreate(); + + if (render) { + // The host element owns its own label/content; we only wire behavior. + // `mergeRenderProp` returns null for an invalid `render` (Fragment/string/ + // array); fall through to the default button in that case. + const merged = mergeRenderProp(render, { type: 'button', disabled, onClick }); + if (merged) return merged; + } + + return ( + + + + ); +} + +CreateAction.displayName = 'SsoProviderTable.CreateAction'; + +/** Props for {@link SsoProviderTableHeader}. */ +export interface SsoProviderTableHeaderProps { + /** Action node rendered in the header's action region. Defaults to {@link CreateAction}. */ + action?: React.ReactNode; + className?: string; +} + +/** + * Title/description region. Owns the header copy; the action region defaults to + * {@link CreateAction} but can be replaced by the host. + * @param props - {@link SsoProviderTableHeaderProps} + * @returns The header element. + */ +function SsoProviderTableHeader({ action, className }: SsoProviderTableHeaderProps) { + const { model, props } = useSsoProviderTableContext(); + const { t } = useTranslator('idp_management.sso_provider_table', props.customMessages); + + return ( +
+
} + /> +
+ ); +} + +SsoProviderTableHeader.displayName = 'SsoProviderTable.Header'; + +/** Props for {@link Refresh}. */ +export interface SsoProviderTableRefreshProps { + className?: string; +} + +/** + * The last-updated / manual-refresh control. Movable — hosts place it before or + * after {@link Content} in a structural layout. + * @param props - {@link SsoProviderTableRefreshProps} + * @returns The refresh control. + */ +function Refresh({ className }: SsoProviderTableRefreshProps) { + const { model, props } = useSsoProviderTableContext(); + return ( +
+ +
+ ); +} + +Refresh.displayName = 'SsoProviderTable.Refresh'; + +/** + * The table body (data table + row actions + modals). Reuses the existing view + * with its header and refresh regions suppressed, since those are owned by the + * {@link SsoProviderTableHeader} and {@link Refresh} parts in composition. + * @returns The table content. + */ +function Content() { + const { model, props } = useSsoProviderTableContext(); + parts.useRegisterPart('Content'); + return ( + + ); +} + +Content.displayName = 'SsoProviderTable.Content'; + +/** + * The default anatomy: header (with create action) → refresh → content. Wrapping + * in `Root` + `DefaultLayout` reproduces the Tier-1 visual output exactly, so + * hosts can opt into composition incrementally. + * @param props - Optional narrow-replacement slot for the create action. + * @param props.children - When provided, replaces the default create action in the header. + * @returns The default layout subtree. + */ +function DefaultLayout({ children }: { children?: React.ReactNode }) { + const { props } = useSsoProviderTableContext(); + return ( + <> + {!props.hideHeader && } + + + + ); +} + +DefaultLayout.displayName = 'SsoProviderTable.DefaultLayout'; + +/** + * SSO identity providers table with progressive composability. + * + * Callable directly for the Tier-1 default (``), + * and exposes compound parts for narrow (Tier 2) and structural (Tier 3) + * composition. For fully headless (Tier 4) usage, see `useSsoProviderTableModel`. + * + * @example Tier 2 — replace the create button + * ```tsx + * + * + * Add connection} /> + * + * + * ``` + * + * @example Tier 3 — structural layout with host UI interleaved + * ```tsx + * + * } />} /> + * + * + * + * + * ``` + */ +const SsoProviderTable = Object.assign(SsoProviderTableDefault, { + Root, + DefaultLayout, + Header: SsoProviderTableHeader, + CreateAction, + Refresh, + Content, +}); + +export { + SsoProviderTable, + Root, + DefaultLayout, + SsoProviderTableHeader as Header, + CreateAction, + Refresh, + Content, + useSsoProviderTableContext, +}; diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx index bf885b389..bfb5db8dc 100644 --- a/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx +++ b/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx @@ -99,6 +99,7 @@ function SsoProviderTableView({ permissions, readOnly, hideHeader, + hideRefresh, hideDeleteProvider, hideRemoveFromOrganization, providers, @@ -227,21 +228,23 @@ function SsoProviderTableView({
)} -
- -
+ {!hideRefresh && ( +
+ +
+ )} { + describe('A1 — landmark', () => { + it('does not expose a banner landmark (reserved for the app shell)', () => { + render(
); + expect(screen.queryByRole('banner')).not.toBeInTheDocument(); + }); + }); + + describe('A2 — heading level', () => { + it('renders the title as

by default (never

)', () => { + render(
); + expect(screen.getByRole('heading', { level: 2, name: 'SSO Providers' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument(); + }); + + it('honors an explicit headingLevel', () => { + render(
); + expect(screen.getByRole('heading', { level: 3, name: 'SSO Providers' })).toBeInTheDocument(); + }); + }); + + describe('A3 — loading keeps the action mounted', () => { + const switchAction: ActionProps = { + type: 'switch', + checked: true, + onCheckedChange: vi.fn(), + 'aria-label': 'Enable provider', + }; + + it('keeps a switch action mounted, disabled, and aria-busy while loading', () => { + render(
); + + const toggle = screen.getByRole('switch', { name: 'Enable provider' }); + expect(toggle).toBeInTheDocument(); + expect(toggle).toBeDisabled(); + expect(toggle).toHaveAttribute('aria-busy', 'true'); + }); + + it('keeps a button action mounted, disabled, and aria-busy while loading', () => { + const buttonAction: ActionProps = { + type: 'button', + label: 'Create', + onClick: vi.fn(), + }; + render(
); + + const button = screen.getByRole('button', { name: 'Create' }); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-busy', 'true'); + }); + + it('renders a non-busy switch normally when not loading', () => { + render(
); + const toggle = screen.getByRole('switch', { name: 'Enable provider' }); + expect(toggle).toBeEnabled(); + expect(toggle).not.toHaveAttribute('aria-busy'); + }); + }); +}); diff --git a/packages/react/src/components/auth0/shared/header.tsx b/packages/react/src/components/auth0/shared/header.tsx index 23edfa3af..d73ad5cb2 100644 --- a/packages/react/src/components/auth0/shared/header.tsx +++ b/packages/react/src/components/auth0/shared/header.tsx @@ -54,8 +54,17 @@ export interface HeaderProps { onClick: (e: React.MouseEvent) => void; }; actions?: ActionProps[]; + /** Arbitrary action node rendered in the actions region. Used by compound + * composition to host a host-replaceable action (e.g. `CreateAction`). */ + actionSlot?: React.ReactNode; isLoading?: boolean; className?: string; + /** + * Heading level for the title. Defaults to `2`: this is a section header + * embedded in a host page, so it must not emit an `

` (the host owns the + * single page-level `

`). Hosts can override to fit their heading outline. + */ + headingLevel?: 1 | 2 | 3 | 4 | 5 | 6; } const WithTooltip: React.FC = ({ trigger, tooltip }) => ( @@ -69,7 +78,7 @@ const WithTooltip: React.FC = ({ trigger, tooltip }) => ( ); -const ButtonAction: React.FC = ({ +const ButtonAction: React.FC = ({ icon: Icon, className, label, @@ -77,32 +86,41 @@ const ButtonAction: React.FC = ({ disabled, variant, size, + busy, }) => ( ); -const SwitchAction: React.FC = ({ +const SwitchAction: React.FC = ({ className, 'aria-label': ariaLabel, checked, onCheckedChange, disabled, + busy, }) => (
+ {busy && }
@@ -111,82 +129,97 @@ const SwitchAction: React.FC = ({ export const Header = React.forwardRef< HTMLDivElement, HeaderProps & React.HTMLAttributes ->(({ title, description, backButton, actions, isLoading, className, ...props }, ref) => { - const BackIcon = backButton?.icon || ArrowLeft; - - const renderAction = (action: ActionProps, index: number) => { - const key = `action-${index}`; - if (isLoading) { - return ; - } - if (action.hidden) { - return null; - } - const actionElement = - action.type === 'switch' ? ( - - ) : ( - - ); - if (action.tooltip) { - return ( - - ); - } - return actionElement; - }; +>( + ( + { + title, + description, + backButton, + actions, + actionSlot, + isLoading, + className, + headingLevel = 2, + ...props + }, + ref, + ) => { + const BackIcon = backButton?.icon || ArrowLeft; + const HeadingTag = `h${headingLevel}` as React.ElementType; + + const renderAction = (action: ActionProps, index: number) => { + const key = `action-${index}`; + if (action.hidden) { + return null; + } + // While loading, keep the control mounted and mark it busy/disabled rather + // than swapping it for a bare spinner — swapping unmounts a focused control + // (focus is lost to ) and never announces the busy state. + const busy = Boolean(isLoading); + const actionElement = + action.type === 'switch' ? ( + + ) : ( + + ); + if (action.tooltip) { + return ( + + ); + } + return actionElement; + }; + + return ( + // Intentionally not a `banner` landmark: this is a section header embedded + // in a host page, and `banner` must be reserved for the app shell (one per + // page). The heading below provides the navigable structure. +
+ {backButton && ( + + )} - return ( -
- {backButton && ( - - )} - -
-
- {title && ( -

- {title} -

- )} - {description && ( -

- {description} -

+
+
+ {title && ( + + {title} + + )} + {description && ( +

+ {description} +

+ )} +
+ + {((actions && actions.length > 0) || actionSlot) && ( +
+ {actions?.map(renderAction)} + {actionSlot} +
)}
- - {actions && actions.length > 0 && ( -
- {actions.map(renderAction)} -
- )}
-
- ); -}); + ); + }, +); Header.displayName = 'Header'; diff --git a/packages/react/src/hooks/my-account/use-user-mfa.ts b/packages/react/src/hooks/my-account/use-user-mfa.ts index e60f131c9..193b1223f 100644 --- a/packages/react/src/hooks/my-account/use-user-mfa.ts +++ b/packages/react/src/hooks/my-account/use-user-mfa.ts @@ -33,7 +33,7 @@ import type { FactorToDelete, OtpData, UseUserMFAOptions, - UseUserMFAReturn, + UseUserMFAResult, } from '@/types/my-account/user-mfa-management/user-mfa-management-types'; const EMPTY_SESSION = { authSession: '', authenticationMethodId: '' }; @@ -61,7 +61,7 @@ export function useUserMFA({ customMessages = {}, enrollAction, deleteAction, -}: UseUserMFAOptions = {}): UseUserMFAReturn { +}: UseUserMFAOptions = {}): UseUserMFAResult { const { t } = useTranslator('user_mfa_management', customMessages); const handleError = useErrorHandler(); const { factorsQuery, enrollMutation, deleteMutation, verifyMutation } = @@ -303,32 +303,62 @@ export function useUserMFA({ [enrollFactor, enrollmentSession, verifyAndComplete], ); - return { - factorsByType, - isLoadingFactors: factorsQuery.isLoading, - isEnrolling: isMutationLoading(enrollMutation), - isDeleting: isMutationLoading(deleteMutation), - isConfirming: isMutationLoading(verifyMutation), - isEnrollDialogOpen, - enrollFactor, - enrollmentPhase, - isDeleteDialogOpen, - factorToDelete, - visibleFactorTypes, - hasNoActiveFactors, - contact, - otpData, - recoveryCode, - handleCancelDelete, - handleConfirmDelete, - handleEnroll, - handleCloseEnrollDialog, - handleDeleteFactor, - handleSendCode, - handleResendCode, - handleConfirmOtp, - handleConfirmPush, - handleConfirmRecoveryCode, - handleEnterQRPhase, - }; + return useMemo( + () => ({ + factorsByType, + isLoadingFactors: factorsQuery.isLoading, + isEnrolling: isMutationLoading(enrollMutation), + isDeleting: isMutationLoading(deleteMutation), + isConfirming: isMutationLoading(verifyMutation), + isEnrollDialogOpen, + enrollFactor, + enrollmentPhase, + isDeleteDialogOpen, + factorToDelete, + visibleFactorTypes, + hasNoActiveFactors, + contact, + otpData, + recoveryCode, + handleCancelDelete, + handleConfirmDelete, + handleEnroll, + handleCloseEnrollDialog, + handleDeleteFactor, + handleSendCode, + handleResendCode, + handleConfirmOtp, + handleConfirmPush, + handleConfirmRecoveryCode, + handleEnterQRPhase, + }), + [ + factorsByType, + factorsQuery.isLoading, + enrollMutation, + deleteMutation, + verifyMutation, + isEnrollDialogOpen, + enrollFactor, + enrollmentPhase, + isDeleteDialogOpen, + factorToDelete, + visibleFactorTypes, + hasNoActiveFactors, + contact, + otpData, + recoveryCode, + handleCancelDelete, + handleConfirmDelete, + handleEnroll, + handleCloseEnrollDialog, + handleDeleteFactor, + handleSendCode, + handleResendCode, + handleConfirmOtp, + handleConfirmPush, + handleConfirmRecoveryCode, + handleEnterQRPhase, + ], + ); } diff --git a/packages/react/src/hooks/my-account/use-user-passkey.ts b/packages/react/src/hooks/my-account/use-user-passkey.ts index c2ea19d89..83b287f33 100644 --- a/packages/react/src/hooks/my-account/use-user-passkey.ts +++ b/packages/react/src/hooks/my-account/use-user-passkey.ts @@ -3,7 +3,7 @@ * @module use-user-passkey */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; import { useUserPasskeyService } from '@/hooks/my-account/shared/services/use-user-passkey-service'; @@ -12,7 +12,7 @@ import { useTranslator } from '@/hooks/shared/use-translator'; import type { Passkey, UseUserPasskeyOptions, - UseUserPasskeyReturn, + UseUserPasskeyResult, } from '@/types/my-account/user-passkey-management/user-passkey-management-types'; type ActiveModal = { mode: 'revoke'; passkey: Passkey } | null; @@ -27,7 +27,7 @@ export function useUserPasskey({ addAction, revokeAction, onFetch, -}: UseUserPasskeyOptions): UseUserPasskeyReturn { +}: UseUserPasskeyOptions): UseUserPasskeyResult { const { t } = useTranslator('passkey', customMessages); const handleError = useErrorHandler(); @@ -91,19 +91,36 @@ export function useUserPasskey({ } }, [currentPasskey, revokeAction, revokeMutation, t, handleError]); - return { - passkeys: passkeysQuery.data ?? [], - isLoading: passkeysQuery.isLoading, - isEnrolling: enrollMutation.isPending, - isRevoking: revokeMutation.isPending, - disableAdd, - disableRevoke, - readOnly, - isRevokeModalOpen, - currentPasskey, - setIsRevokeModalOpen: closeModal, - handleAddPasskey, - handleRevokePasskey, - handleConfirmRevoke, - }; + return useMemo( + () => ({ + passkeys: passkeysQuery.data ?? [], + isLoading: passkeysQuery.isLoading, + isEnrolling: enrollMutation.isPending, + isRevoking: revokeMutation.isPending, + disableAdd, + disableRevoke, + readOnly, + isRevokeModalOpen, + currentPasskey, + setIsRevokeModalOpen: closeModal, + handleAddPasskey, + handleRevokePasskey, + handleConfirmRevoke, + }), + [ + passkeysQuery.data, + passkeysQuery.isLoading, + enrollMutation.isPending, + revokeMutation.isPending, + disableAdd, + disableRevoke, + readOnly, + isRevokeModalOpen, + currentPasskey, + closeModal, + handleAddPasskey, + handleRevokePasskey, + handleConfirmRevoke, + ], + ); } diff --git a/packages/react/src/hooks/my-organization/use-domain-table.ts b/packages/react/src/hooks/my-organization/use-domain-table.ts index 0e4ba3ade..e5e217b01 100644 --- a/packages/react/src/hooks/my-organization/use-domain-table.ts +++ b/packages/react/src/hooks/my-organization/use-domain-table.ts @@ -20,7 +20,7 @@ import { useTranslator } from '@/hooks/shared/use-translator'; import { DEFAULT_PAGE_SIZE_OPTIONS } from '@/lib/constants/shared/constants'; import type { UseDomainTableOptions, - UseDomainTableReturn, + UseDomainTableResult, } from '@/types/my-organization/domain-management/domain-table-types'; /** @@ -37,7 +37,7 @@ export function useDomainTable({ associateToProviderAction, deleteFromProviderAction, customMessages, -}: UseDomainTableOptions): UseDomainTableReturn { +}: UseDomainTableOptions): UseDomainTableResult { const { t } = useTranslator('domain_management', customMessages); const handleError = useErrorHandler(); const { createPermissionResolver } = usePermissions(); @@ -301,57 +301,99 @@ export function useDomainTable({ [changePageSize], ); - return { - permissions, + return useMemo( + () => ({ + permissions, - // Data - domains, - providers, + // Data + domains, + providers, - // Loading states - isFetching, - isRefetchingDomains, - isDomainsStale, - domainsUpdatedAt, - isCreating, - isDeleting, - isVerifying, - isLoadingProviders, - // Pagination - pagination: { - pageSize, - currentPage, - hasNextPage: !!nextToken, - hasPreviousPage, - }, + // Loading states + isFetching, + isRefetchingDomains, + isDomainsStale, + domainsUpdatedAt, + isCreating, + isDeleting, + isVerifying, + isLoadingProviders, + // Pagination + pagination: { + pageSize, + currentPage, + hasNextPage: !!nextToken, + hasPreviousPage, + }, - // Modal state - showCreateModal, - showConfigureModal, - showVerifyModal, - showDeleteModal, - verifyError, - selectedDomain, + // Modal state + showCreateModal, + showConfigureModal, + showVerifyModal, + showDeleteModal, + verifyError, + selectedDomain, - // State setters - setShowCreateModal, - setShowConfigureModal, - setShowVerifyModal, - setShowDeleteModal, + // State setters + setShowCreateModal, + setShowConfigureModal, + setShowVerifyModal, + setShowDeleteModal, - // Handlers - refetchDomains, - handleCreate, - handleVerify, - handleDelete, - handleToggleSwitch, - handleCloseVerifyModal, - handleCreateClick, - handleConfigureClick, - handleVerifyClick, - handleDeleteClick, - handleNextPage, - handlePreviousPage, - handlePageSizeChange, - }; + // Handlers + refetchDomains, + handleCreate, + handleVerify, + handleDelete, + handleToggleSwitch, + handleCloseVerifyModal, + handleCreateClick, + handleConfigureClick, + handleVerifyClick, + handleDeleteClick, + handleNextPage, + handlePreviousPage, + handlePageSizeChange, + }), + [ + permissions, + domains, + providers, + isFetching, + isRefetchingDomains, + isDomainsStale, + domainsUpdatedAt, + isCreating, + isDeleting, + isVerifying, + isLoadingProviders, + pageSize, + currentPage, + nextToken, + hasPreviousPage, + showCreateModal, + showConfigureModal, + showVerifyModal, + showDeleteModal, + verifyError, + selectedDomain, + setShowCreateModal, + setShowConfigureModal, + setShowVerifyModal, + setShowDeleteModal, + refetchDomains, + handleCreate, + handleVerify, + handleDelete, + handleToggleSwitch, + handleCloseVerifyModal, + handleCreateClick, + handleConfigureClick, + handleVerifyClick, + handleDeleteClick, + handleNextPage, + handlePreviousPage, + handlePageSizeChange, + ], + ); } diff --git a/packages/react/src/hooks/my-organization/use-member-detail.ts b/packages/react/src/hooks/my-organization/use-member-detail.ts index 9ed18fef8..728865f81 100644 --- a/packages/react/src/hooks/my-organization/use-member-detail.ts +++ b/packages/react/src/hooks/my-organization/use-member-detail.ts @@ -172,34 +172,65 @@ export function useOrganizationMemberDetail( ? resolveErrorMessage(memberQuery.error, t('member.detail.error.fetch_failed')) : null; - return { - activeTab, - permissions, - member, - organizationDisplayName, - memberRoles, - searchedRoles, - onRoleSearch: setRoleSearchTerm, - selectedRoles, - memberError: memberErrorMessage, - isFetchingMember: memberQuery.isLoading || memberQuery.isFetching, - isFetchingMemberRoles: memberRolesQuery.isLoading, - isSearchingRoles: rolesSearchQuery.isFetching, - isLoading: memberQuery.isLoading, - isRemovingFromOrganization: isMutationLoading(removeFromOrganizationMutation), - isAssigningRoles: isMutationLoading(assignRolesMutation), - isRemovingRoles: isMutationLoading(removeRolesMutation), - removingRoleIds: isMutationLoading(removeRolesMutation) ? removingRoles.map((r) => r.id) : [], - modalState, - - setActiveTab, - setSelectedRoles, - handleBack, - openModal, - closeModal, - handleRemoveFromOrganizationConfirm, - handleAssignRolesSubmit, - handleRemoveRolesCancel, - handleRemoveRolesConfirm, - }; + return React.useMemo( + () => ({ + activeTab, + permissions, + member, + organizationDisplayName, + memberRoles, + searchedRoles, + onRoleSearch: setRoleSearchTerm, + selectedRoles, + memberError: memberErrorMessage, + isFetchingMember: memberQuery.isLoading || memberQuery.isFetching, + isFetchingMemberRoles: memberRolesQuery.isLoading, + isSearchingRoles: rolesSearchQuery.isFetching, + isLoading: memberQuery.isLoading, + isRemovingFromOrganization: isMutationLoading(removeFromOrganizationMutation), + isAssigningRoles: isMutationLoading(assignRolesMutation), + isRemovingRoles: isMutationLoading(removeRolesMutation), + removingRoleIds: isMutationLoading(removeRolesMutation) ? removingRoles.map((r) => r.id) : [], + modalState, + + setActiveTab, + setSelectedRoles, + handleBack, + openModal, + closeModal, + handleRemoveFromOrganizationConfirm, + handleAssignRolesSubmit, + handleRemoveRolesCancel, + handleRemoveRolesConfirm, + }), + [ + activeTab, + permissions, + member, + organizationDisplayName, + memberRoles, + searchedRoles, + setRoleSearchTerm, + selectedRoles, + memberErrorMessage, + memberQuery.isLoading, + memberQuery.isFetching, + memberRolesQuery.isLoading, + rolesSearchQuery.isFetching, + removeFromOrganizationMutation, + assignRolesMutation, + removeRolesMutation, + removingRoles, + modalState, + setActiveTab, + setSelectedRoles, + handleBack, + openModal, + closeModal, + handleRemoveFromOrganizationConfirm, + handleAssignRolesSubmit, + handleRemoveRolesCancel, + handleRemoveRolesConfirm, + ], + ); } diff --git a/packages/react/src/hooks/my-organization/use-organization-details-edit.ts b/packages/react/src/hooks/my-organization/use-organization-details-edit.ts index 1db2fc8d9..189be5577 100644 --- a/packages/react/src/hooks/my-organization/use-organization-details-edit.ts +++ b/packages/react/src/hooks/my-organization/use-organization-details-edit.ts @@ -89,19 +89,36 @@ export function useOrganizationDetailsEdit({ ], ); - return { - permissions, - canEdit, - organization: service.organization, - isFetchLoading: service.isFetchLoading, - isSaveLoading: service.isSaveLoading, - isInitializing: service.isInitializing, - isLoadingConfig, - formActions, - fetchOrgDetails: service.fetchOrgDetails, - updateOrgDetails: service.updateOrgDetails, - showThirdPartyAccess, - isThirdPartyAccessReadOnly, - thirdPartyAccessDefaultValue, - }; + return useMemo( + () => ({ + permissions, + canEdit, + organization: service.organization, + isFetchLoading: service.isFetchLoading, + isSaveLoading: service.isSaveLoading, + isInitializing: service.isInitializing, + isLoadingConfig, + formActions, + fetchOrgDetails: service.fetchOrgDetails, + updateOrgDetails: service.updateOrgDetails, + showThirdPartyAccess, + isThirdPartyAccessReadOnly, + thirdPartyAccessDefaultValue, + }), + [ + permissions, + canEdit, + service.organization, + service.isFetchLoading, + service.isSaveLoading, + service.isInitializing, + isLoadingConfig, + formActions, + service.fetchOrgDetails, + service.updateOrgDetails, + showThirdPartyAccess, + isThirdPartyAccessReadOnly, + thirdPartyAccessDefaultValue, + ], + ); } 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 index 6930af31c..0fb1d1dc7 100644 --- a/packages/react/src/hooks/my-organization/use-organization-member-management.ts +++ b/packages/react/src/hooks/my-organization/use-organization-member-management.ts @@ -343,77 +343,139 @@ export function useOrganizationMemberManagement( [activeTab, invitationChangeFilters, memberChangeFilters], ); - return { - activeTab, - permissions, - searchedRoles, - onRoleSearch: setRoleSearchTerm, - availableConnections, - isLoadingConnections, - hasNoConnections, - - invitations: currentInvitations, - members: currentMembers, - organizationDisplayName: organizationDisplayName, - isInitialLoading: membersQuery.isLoading, - isFetchingInvitations: invitationsQuery.isFetching, - isLoadingInvitations: invitationsQuery.isLoading, - isFetchingMembers: membersQuery.isFetching, - isMembersStale: membersQuery.isStale, - isInvitationsStale: invitationsQuery.isStale, - membersUpdatedAt: membersQuery.dataUpdatedAt, - invitationsUpdatedAt: invitationsQuery.dataUpdatedAt, - refetchMembers: membersQuery.refetch, - refetchInvitations: invitationsQuery.refetch, - invitationRoles, - isFetchingInvitationRoles: invitationRolesQuery.isLoading, - isSearchingRoles: rolesSearchQuery.isFetching, - isRemovingFromOrganization: isMutationLoading(removeFromOrganizationMutation), - isAssigningRoles: isMutationLoading(assignRolesMutation), - isLoadingMemberRoles: memberRolesQuery.isLoading, - memberRoles: memberRolesQuery.data, - isCreatingInvitation: isMutationLoading(createInvitationMutation), - isRevokingInvitation: isMutationLoading(revokeInvitationMutation), - isResendingInvitation: isMutationLoading(resendInvitationMutation), - selectedInvitations, - invitationPagination: { - pageSize: invitationPageSize, - currentPage: invitationCurrentPage, - totalItems: invitationTotal, - totalItemsDisplay: formatMemberCount(invitationTotal, invitationTotalIsCapped, t, locale), - hasNextPage: !!invitationNextToken, - hasPreviousPage: invitationHasPreviousPage, - }, - memberPagination: { - pageSize: memberPageSize, - currentPage: memberCurrentPage, - totalItems: memberTotal, - totalItemsDisplay: formatMemberCount(memberTotal, memberTotalIsCapped, t, locale), - hasNextPage: !!memberNextToken, - hasPreviousPage: memberHasPreviousPage, - }, - invitationFilters, - invitationSortConfig, - memberFilters, - memberSortConfig, - modalState, - - setActiveTab, - openModal, - closeModal, - onSelectedInvitationsChange: setSelectedInvitations, - handleCreateSubmit, - handleRevokeConfirm, - handleRevokeResendConfirm, - handleBulkRevokeClick, - handleCopyUrl, - handleNextPage, - handlePreviousPage, - handlePageSizeChange, - handleSortChange, - handleRoleFilterChange, - handleViewMemberDetails, - handleAssignRolesSubmit, - handleRemoveFromOrganizationConfirm, - }; + return React.useMemo( + () => ({ + activeTab, + permissions, + searchedRoles, + onRoleSearch: setRoleSearchTerm, + availableConnections, + isLoadingConnections, + hasNoConnections, + + invitations: currentInvitations, + members: currentMembers, + organizationDisplayName: organizationDisplayName, + isInitialLoading: membersQuery.isLoading, + isFetchingInvitations: invitationsQuery.isFetching, + isLoadingInvitations: invitationsQuery.isLoading, + isFetchingMembers: membersQuery.isFetching, + isMembersStale: membersQuery.isStale, + isInvitationsStale: invitationsQuery.isStale, + membersUpdatedAt: membersQuery.dataUpdatedAt, + invitationsUpdatedAt: invitationsQuery.dataUpdatedAt, + refetchMembers: membersQuery.refetch, + refetchInvitations: invitationsQuery.refetch, + invitationRoles, + isFetchingInvitationRoles: invitationRolesQuery.isLoading, + isSearchingRoles: rolesSearchQuery.isFetching, + isRemovingFromOrganization: isMutationLoading(removeFromOrganizationMutation), + isAssigningRoles: isMutationLoading(assignRolesMutation), + isLoadingMemberRoles: memberRolesQuery.isLoading, + memberRoles: memberRolesQuery.data, + isCreatingInvitation: isMutationLoading(createInvitationMutation), + isRevokingInvitation: isMutationLoading(revokeInvitationMutation), + isResendingInvitation: isMutationLoading(resendInvitationMutation), + selectedInvitations, + invitationPagination: { + pageSize: invitationPageSize, + currentPage: invitationCurrentPage, + totalItems: invitationTotal, + totalItemsDisplay: formatMemberCount(invitationTotal, invitationTotalIsCapped, t, locale), + hasNextPage: !!invitationNextToken, + hasPreviousPage: invitationHasPreviousPage, + }, + memberPagination: { + pageSize: memberPageSize, + currentPage: memberCurrentPage, + totalItems: memberTotal, + totalItemsDisplay: formatMemberCount(memberTotal, memberTotalIsCapped, t, locale), + hasNextPage: !!memberNextToken, + hasPreviousPage: memberHasPreviousPage, + }, + invitationFilters, + invitationSortConfig, + memberFilters, + memberSortConfig, + modalState, + + setActiveTab, + openModal, + closeModal, + onSelectedInvitationsChange: setSelectedInvitations, + handleCreateSubmit, + handleRevokeConfirm, + handleRevokeResendConfirm, + handleBulkRevokeClick, + handleCopyUrl, + handleNextPage, + handlePreviousPage, + handlePageSizeChange, + handleSortChange, + handleRoleFilterChange, + handleViewMemberDetails, + handleAssignRolesSubmit, + handleRemoveFromOrganizationConfirm, + }), + [ + activeTab, + permissions, + searchedRoles, + setRoleSearchTerm, + availableConnections, + isLoadingConnections, + hasNoConnections, + currentInvitations, + currentMembers, + organizationDisplayName, + membersQuery, + invitationsQuery, + invitationRoles, + invitationRolesQuery, + rolesSearchQuery, + removeFromOrganizationMutation, + assignRolesMutation, + memberRolesQuery, + createInvitationMutation, + revokeInvitationMutation, + resendInvitationMutation, + selectedInvitations, + invitationPageSize, + invitationCurrentPage, + invitationTotal, + invitationTotalIsCapped, + t, + locale, + invitationNextToken, + invitationHasPreviousPage, + memberPageSize, + memberCurrentPage, + memberTotal, + memberTotalIsCapped, + memberNextToken, + memberHasPreviousPage, + invitationFilters, + invitationSortConfig, + memberFilters, + memberSortConfig, + modalState, + setActiveTab, + openModal, + closeModal, + setSelectedInvitations, + handleCreateSubmit, + handleRevokeConfirm, + handleRevokeResendConfirm, + handleBulkRevokeClick, + handleCopyUrl, + handleNextPage, + handlePreviousPage, + handlePageSizeChange, + handleSortChange, + handleRoleFilterChange, + handleViewMemberDetails, + handleAssignRolesSubmit, + handleRemoveFromOrganizationConfirm, + ], + ); } diff --git a/packages/react/src/hooks/my-organization/use-sso-provider-create.ts b/packages/react/src/hooks/my-organization/use-sso-provider-create.ts index 3620b6f20..c32682e90 100644 --- a/packages/react/src/hooks/my-organization/use-sso-provider-create.ts +++ b/packages/react/src/hooks/my-organization/use-sso-provider-create.ts @@ -102,25 +102,49 @@ export function useSsoProviderCreate({ }); }, [permissions, strategy, details, createProvider]); - return { - permissions, - formData, - setFormData, - createStepActions, - handleCreate, - detailsRef, - configureRef, - isCreating, - isLoadingConfig, - filteredStrategies, - isLoadingIdpConfig, - idpConfig, - showThirdPartyAccess, - isThirdPartyAccessReadOnly, - thirdPartyAccessDefaultValue, - showCrossAppAccess: showCrossAppAccess(strategy), - isCrossAppAccessReadOnly: isCrossAppAccessReadOnly(strategy), - getCrossAppAccessDefaultValue: () => getCrossAppAccessDefaultValue(strategy), - isOrganizationBlocked, - }; + return useMemo( + () => ({ + permissions, + formData, + setFormData, + createStepActions, + handleCreate, + detailsRef, + configureRef, + isCreating, + isLoadingConfig, + filteredStrategies, + isLoadingIdpConfig, + idpConfig, + showThirdPartyAccess, + isThirdPartyAccessReadOnly, + thirdPartyAccessDefaultValue, + showCrossAppAccess: showCrossAppAccess(strategy), + isCrossAppAccessReadOnly: isCrossAppAccessReadOnly(strategy), + getCrossAppAccessDefaultValue: () => getCrossAppAccessDefaultValue(strategy), + isOrganizationBlocked, + }), + [ + permissions, + formData, + setFormData, + createStepActions, + handleCreate, + detailsRef, + configureRef, + isCreating, + isLoadingConfig, + filteredStrategies, + isLoadingIdpConfig, + idpConfig, + showThirdPartyAccess, + isThirdPartyAccessReadOnly, + thirdPartyAccessDefaultValue, + showCrossAppAccess, + isCrossAppAccessReadOnly, + getCrossAppAccessDefaultValue, + strategy, + isOrganizationBlocked, + ], + ); } diff --git a/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts b/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts index b6757e737..627fc5666 100644 --- a/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts +++ b/packages/react/src/hooks/my-organization/use-sso-provider-edit.ts @@ -13,7 +13,7 @@ import { useSsoProviderEditService } from '@/hooks/my-organization/shared/servic import { usePermissions } from '@/hooks/shared/use-permissions'; import type { UseSsoProviderEditOptions, - UseSsoProviderEditReturn, + UseSsoProviderEditResult, } from '@/types/my-organization/idp-management/sso-provider/sso-provider-edit-types'; /** @@ -50,7 +50,7 @@ export function useSsoProviderEdit( readOnly = false, enableProviderAction, }: Partial = {}, -): UseSsoProviderEditReturn { +): UseSsoProviderEditResult { const { createPermissionResolver } = usePermissions(); const permissions = useMemo( @@ -123,27 +123,52 @@ export function useSsoProviderEdit( service.deleteScimToken, ); - return { - ...service, - updateProvider, - syncSsoAttributes, - onDeleteConfirm, - onRemoveConfirm, - createProvisioning, - deleteProvisioning, - syncProvisioningAttributes, - createScimToken, - deleteScimToken, - permissions, - shouldAllowDeletion, - isLoadingConfig, - idpConfig, - isLoadingIdpConfig, - showProvisioningTab, - handleToggleProvider, - showThirdPartyAccess, - isThirdPartyAccessReadOnly, - showCrossAppAccess: showCrossAppAccess(service.provider?.strategy), - isCrossAppAccessReadOnly: isCrossAppAccessReadOnly(service.provider?.strategy), - }; + return useMemo( + () => ({ + ...service, + updateProvider, + syncSsoAttributes, + onDeleteConfirm, + onRemoveConfirm, + createProvisioning, + deleteProvisioning, + syncProvisioningAttributes, + createScimToken, + deleteScimToken, + permissions, + shouldAllowDeletion, + isLoadingConfig, + idpConfig, + isLoadingIdpConfig, + showProvisioningTab, + handleToggleProvider, + showThirdPartyAccess, + isThirdPartyAccessReadOnly, + showCrossAppAccess: showCrossAppAccess(service.provider?.strategy), + isCrossAppAccessReadOnly: isCrossAppAccessReadOnly(service.provider?.strategy), + }), + [ + service, + updateProvider, + syncSsoAttributes, + onDeleteConfirm, + onRemoveConfirm, + createProvisioning, + deleteProvisioning, + syncProvisioningAttributes, + createScimToken, + deleteScimToken, + permissions, + shouldAllowDeletion, + isLoadingConfig, + idpConfig, + isLoadingIdpConfig, + showProvisioningTab, + handleToggleProvider, + showThirdPartyAccess, + isThirdPartyAccessReadOnly, + showCrossAppAccess, + isCrossAppAccessReadOnly, + ], + ); } diff --git a/packages/react/src/hooks/my-organization/use-sso-provider-table.ts b/packages/react/src/hooks/my-organization/use-sso-provider-table.ts index 49812ab1b..bb9eacdd4 100644 --- a/packages/react/src/hooks/my-organization/use-sso-provider-table.ts +++ b/packages/react/src/hooks/my-organization/use-sso-provider-table.ts @@ -20,7 +20,7 @@ import { usePermissions } from '@/hooks/shared/use-permissions'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { UseSsoProviderTableOptions, - UseSsoProviderTableReturn, + UseSsoProviderTableResult, } from '@/types/my-organization/idp-management/sso-provider/sso-provider-table-types'; export { ssoProviderQueryKeys }; @@ -39,7 +39,7 @@ export function useSsoProviderTable({ deleteAction, deleteFromOrganizationAction, enableProviderAction, -}: UseSsoProviderTableOptions): UseSsoProviderTableReturn { +}: UseSsoProviderTableOptions): UseSsoProviderTableResult { const { t } = useTranslator( 'idp_management.notifications', customMessages as Record, @@ -221,42 +221,76 @@ export function useSsoProviderTable({ [onRemoveConfirm, organization?.display_name, t, handleError], ); - return { - permissions, - providers, - organization, + return useMemo( + () => ({ + permissions, + providers, + organization, - isLoading, - isViewLoading, - isRefetchingProviders, - isProvidersStale, - providersUpdatedAt, - isDeleting, - isRemoving, - isUpdating, - isUpdatingId, + isLoading, + isViewLoading, + isRefetchingProviders, + isProvidersStale, + providersUpdatedAt, + isDeleting, + isRemoving, + isUpdating, + isUpdatingId, - shouldAllowDeletion, - shouldHideCreate, + shouldAllowDeletion, + shouldHideCreate, - showDeleteModal, - showRemoveModal, - selectedIdp, + showDeleteModal, + showRemoveModal, + selectedIdp, - refetchProviders, - fetchProviders, - fetchOrganizationDetails: handleFetchOrganizationDetails, + refetchProviders, + fetchProviders, + fetchOrganizationDetails: handleFetchOrganizationDetails, - handleCreate, - handleEdit, - handleDelete, - handleDeleteFromOrganization, - handleToggleEnabled, - handleDeleteConfirm, - handleRemoveConfirm, + handleCreate, + handleEdit, + handleDelete, + handleDeleteFromOrganization, + handleToggleEnabled, + handleDeleteConfirm, + handleRemoveConfirm, - setShowDeleteModal, - setShowRemoveModal, - setSelectedIdp, - }; + setShowDeleteModal, + setShowRemoveModal, + setSelectedIdp, + }), + [ + permissions, + providers, + organization, + isLoading, + isViewLoading, + isRefetchingProviders, + isProvidersStale, + providersUpdatedAt, + isDeleting, + isRemoving, + isUpdating, + isUpdatingId, + shouldAllowDeletion, + shouldHideCreate, + showDeleteModal, + showRemoveModal, + selectedIdp, + refetchProviders, + fetchProviders, + handleFetchOrganizationDetails, + handleCreate, + handleEdit, + handleDelete, + handleDeleteFromOrganization, + handleToggleEnabled, + handleDeleteConfirm, + handleRemoveConfirm, + setShowDeleteModal, + setShowRemoveModal, + setSelectedIdp, + ], + ); } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5a32e6372..e7eff70ec 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -2,40 +2,47 @@ * Unified entry point for Auth0 UI components, hooks & types. */ +// Deprecated bare hook aliases are re-declared as local `const` exports (in the +// hook sections below) rather than `export { useX } from '...'`. tsup's dts +// bundler drops the JSDoc from re-export statements, so a `@deprecated` on a bare +// re-export never reaches consumers' `.d.ts`. A local `const` is emitted as its +// own declaration and carries the tag through. Each canonical `useXModel` alias +// re-exports the same impl binding and stays non-deprecated. +import { useUserMFA as useUserMFAImpl } from './hooks/my-account/use-user-mfa'; +import { useUserPasskey as useUserPasskeyImpl } from './hooks/my-account/use-user-passkey'; +import { useDomainTable as useDomainTableImpl } from './hooks/my-organization/use-domain-table'; +import { useOrganizationMemberDetail as useOrganizationMemberDetailImpl } from './hooks/my-organization/use-member-detail'; +import { useOrganizationDetailsEdit as useOrganizationDetailsEditImpl } from './hooks/my-organization/use-organization-details-edit'; +import { useOrganizationMemberManagement as useOrganizationMemberManagementImpl } from './hooks/my-organization/use-organization-member-management'; +import { useSsoProviderCreate as useSsoProviderCreateImpl } from './hooks/my-organization/use-sso-provider-create'; +import { useSsoProviderEdit as useSsoProviderEditImpl } from './hooks/my-organization/use-sso-provider-edit'; +import { useSsoProviderTable as useSsoProviderTableImpl } from './hooks/my-organization/use-sso-provider-table'; + // Components -export { - UserMFAManagement, - UserMFAManagementView, -} from './components/auth0/my-account/user-mfa-management'; -export { - UserPasskeyManagement, - UserPasskeyManagementView, -} from './components/auth0/my-account/user-passkey-management'; -export { - SsoProviderEdit, - SsoProviderEditView, -} from './components/auth0/my-organization/sso-provider-edit'; -export { - SsoProviderCreate, - SsoProviderCreateView, -} from './components/auth0/my-organization/sso-provider-create'; -export { - SsoProviderTable, - SsoProviderTableView, -} from './components/auth0/my-organization/sso-provider-table'; -export { DomainTable, DomainTableView } from './components/auth0/my-organization/domain-table'; -export { - OrganizationMemberManagement, - OrganizationMemberManagementView, -} from './components/auth0/my-organization/organization-member-management'; -export { - OrganizationMemberDetail, - OrganizationMemberDetailView, -} from './components/auth0/my-organization/organization-member-detail'; -export { - OrganizationDetailsEdit, - OrganizationDetailsEditView, -} from './components/auth0/my-organization/organization-details-edit'; +// +// Composability: each block component below is re-exported from its +// `.composable` module (the Tier-1 callable default plus the compound parts: +// `Root`, and where the architecture supports them `Header`/`Content`/action +// parts). The paired `*View` stays sourced from the base module. Tier-4 headless +// model hooks are re-exported under stable `use*Model` aliases further below. +export { UserMFAManagementView } from './components/auth0/my-account/user-mfa-management'; +export { UserMFAManagement } from './components/auth0/my-account/user-mfa-management.composable'; +export { UserPasskeyManagementView } from './components/auth0/my-account/user-passkey-management'; +export { UserPasskeyManagement } from './components/auth0/my-account/user-passkey-management.composable'; +export { SsoProviderEditView } from './components/auth0/my-organization/sso-provider-edit'; +export { SsoProviderEdit } from './components/auth0/my-organization/sso-provider-edit.composable'; +export { SsoProviderCreateView } from './components/auth0/my-organization/sso-provider-create'; +export { SsoProviderCreate } from './components/auth0/my-organization/sso-provider-create.composable'; +export { SsoProviderTableView } from './components/auth0/my-organization/sso-provider-table'; +export { SsoProviderTable } from './components/auth0/my-organization/sso-provider-table.composable'; +export { DomainTableView } from './components/auth0/my-organization/domain-table'; +export { DomainTable } from './components/auth0/my-organization/domain-table.composable'; +export { OrganizationMemberManagementView } from './components/auth0/my-organization/organization-member-management'; +export { OrganizationMemberManagement } from './components/auth0/my-organization/organization-member-management.composable'; +export { OrganizationMemberDetailView } from './components/auth0/my-organization/organization-member-detail'; +export { OrganizationMemberDetail } from './components/auth0/my-organization/organization-member-detail.composable'; +export { OrganizationDetailsEditView } from './components/auth0/my-organization/organization-details-edit'; +export { OrganizationDetailsEdit } from './components/auth0/my-organization/organization-details-edit.composable'; // Providers export { PermissionProvider } from './providers/permission-provider'; @@ -49,23 +56,42 @@ export { useErrorHandler } from './hooks/shared/use-error-handler'; export { usePermissions } from './hooks/shared/use-permissions'; // My Account hooks -export { useUserMFA } from './hooks/my-account/use-user-mfa'; -export { useUserPasskey } from './hooks/my-account/use-user-passkey'; +/** @deprecated Use {@link useUserMFAModel} instead; the bare alias is removed next major. */ +export const useUserMFA = useUserMFAImpl; +/** @deprecated Use {@link useUserPasskeyModel} instead; the bare alias is removed next major. */ +export const useUserPasskey = useUserPasskeyImpl; +// Tier-4 headless aliases — stable public names for the model hooks. +export { useUserMFAImpl as useUserMFAModel, useUserPasskeyImpl as useUserPasskeyModel }; // My Organization hooks export { useConfig } from './hooks/my-organization/shared/services/use-config-service'; export { useIdpConfig } from './hooks/my-organization/shared/services/use-idp-config-service'; -export { useOrganizationDetailsEdit } from './hooks/my-organization/use-organization-details-edit'; -export { useDomainTable } from './hooks/my-organization/use-domain-table'; +/** @deprecated Use {@link useOrganizationDetailsEditModel} instead; the bare alias is removed next major. */ +export const useOrganizationDetailsEdit = useOrganizationDetailsEditImpl; +export { useOrganizationDetailsEditImpl as useOrganizationDetailsEditModel }; +/** @deprecated Use {@link useDomainTableModel} instead; the bare alias is removed next major. */ +export const useDomainTable = useDomainTableImpl; +export { useDomainTableImpl as useDomainTableModel }; export { useProviderFormMode } from './hooks/my-organization/use-provider-form-mode'; export { useSsoDomainTab } from './hooks/my-organization/use-sso-domain-tab'; -export { useSsoProviderCreate } from './hooks/my-organization/use-sso-provider-create'; -export { useSsoProviderEdit } from './hooks/my-organization/use-sso-provider-edit'; -export { useSsoProviderTable } from './hooks/my-organization/use-sso-provider-table'; +/** @deprecated Use {@link useSsoProviderCreateModel} instead; the bare alias is removed next major. */ +export const useSsoProviderCreate = useSsoProviderCreateImpl; +export { useSsoProviderCreateImpl as useSsoProviderCreateModel }; +/** @deprecated Use {@link useSsoProviderEditModel} instead; the bare alias is removed next major. */ +export const useSsoProviderEdit = useSsoProviderEditImpl; +export { useSsoProviderEditImpl as useSsoProviderEditModel }; +/** @deprecated Use {@link useSsoProviderTableModel} instead; the bare alias is removed next major. */ +export const useSsoProviderTable = useSsoProviderTableImpl; +// Tier-4 headless alias — stable public name for the model hook. +export { useSsoProviderTableImpl as useSsoProviderTableModel }; // Member Management hooks -export { useOrganizationMemberManagement } from './hooks/my-organization/use-organization-member-management'; -export { useOrganizationMemberDetail } from './hooks/my-organization/use-member-detail'; +/** @deprecated Use {@link useOrganizationMemberManagementModel} instead; the bare alias is removed next major. */ +export const useOrganizationMemberManagement = useOrganizationMemberManagementImpl; +export { useOrganizationMemberManagementImpl as useOrganizationMemberManagementModel }; +/** @deprecated Use {@link useOrganizationMemberDetailModel} instead; the bare alias is removed next major. */ +export const useOrganizationMemberDetail = useOrganizationMemberDetailImpl; +export { useOrganizationMemberDetailImpl as useOrganizationMemberDetailModel }; // Auth types export * from './types/auth-types'; diff --git a/packages/react/src/lib/composability/__tests__/create-component-context.test.tsx b/packages/react/src/lib/composability/__tests__/create-component-context.test.tsx new file mode 100644 index 000000000..37babb89f --- /dev/null +++ b/packages/react/src/lib/composability/__tests__/create-component-context.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createComponentContext } from '@/lib/composability/create-component-context'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** + * Builds a minimal compound component off the factory: a `Root` (provides the + * value + wraps children in the dev `Boundary`), a required `Content` part that + * registers itself, and an optional `Header` part that does not. + */ +function makeWidget(requiredParts?: readonly string[]) { + const [Context, useWidgetContext, parts] = createComponentContext<{ value: number }>('Widget', { + requiredParts, + }); + + function Root({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + } + + function Content() { + parts.useRegisterPart('Content'); + useWidgetContext(); + return
content
; + } + + function Header() { + useWidgetContext(); + return
header
; + } + + return { Root, Content, Header }; +} + +describe('createComponentContext — missing-required-part warning', () => { + it('warns, naming the missing part, when a required part never mounts', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { Root, Header } = makeWidget(['Content']); + + render( + +
+ , + ); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('')); + }); + + it('does not warn when the required part is present, even nested in host markup', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { Root, Content, Header } = makeWidget(['Content']); + + // Content is wrapped in host elements — a shallow children scan would miss + // it; effect registration finds it regardless of nesting depth. + render( + +
+
+
+ +
+
+ , + ); + + expect(screen.getByText('content')).toBeInTheDocument(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('never warns when the component declares no required parts', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { Root, Header } = makeWidget(); + + render( + +
+ , + ); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('does not warn in production builds', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + const { Root, Header } = makeWidget(['Content']); + render( + +
+ , + ); + expect(warn).not.toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = previous; + } + }); + + it('does not warn under StrictMode double-invocation when the part is present', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { Root, Content } = makeWidget(['Content']); + + render( + + + + + , + ); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('still throws when a compound part is rendered outside Root', () => { + // The guard hook is unchanged; a part read without a provider must throw. + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { Header } = makeWidget(['Content']); + + expect(() => render(
)).toThrow( + 'Widget compound parts must be rendered inside .', + ); + error.mockRestore(); + }); +}); diff --git a/packages/react/src/lib/composability/__tests__/render-prop.test.tsx b/packages/react/src/lib/composability/__tests__/render-prop.test.tsx new file mode 100644 index 000000000..74d8c425a --- /dev/null +++ b/packages/react/src/lib/composability/__tests__/render-prop.test.tsx @@ -0,0 +1,110 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { mergeRenderProp } from '@/lib/composability/render-prop'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('mergeRenderProp', () => { + it('clones a valid element and wires the component behavior', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + + const merged = mergeRenderProp(, { + type: 'button', + disabled: false, + onClick, + }); + + expect(merged).not.toBeNull(); + render(merged as React.ReactElement); + + const button = screen.getByRole('button', { name: 'Add' }); + expect(button).toHaveAttribute('type', 'button'); + await user.click(button); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('preserves host props (children/className) — host wins for non-behavioral props', () => { + const merged = mergeRenderProp(, { + type: 'button', + onClick: vi.fn(), + }); + + render(merged as React.ReactElement); + const button = screen.getByRole('button', { name: 'Add domain' }); + expect(button).toHaveClass('host-cls'); + }); + + it('chains onClick: host runs first, component action skipped on preventDefault', async () => { + const user = userEvent.setup(); + const ownOnClick = vi.fn(); + const hostOnClick = vi.fn((e: React.MouseEvent) => e.preventDefault()); + + const merged = mergeRenderProp(, { + onClick: ownOnClick, + }); + + render(merged as React.ReactElement); + await user.click(screen.getByRole('button', { name: 'Add' })); + + expect(hostOnClick).toHaveBeenCalledTimes(1); + expect(ownOnClick).not.toHaveBeenCalled(); + }); + + it('disables when EITHER the host or the component sets disabled (union, not "component wins")', () => { + // Component says not-disabled, host says disabled → merged is disabled. + const merged = mergeRenderProp(, { + disabled: false, + onClick: vi.fn(), + }); + + render(merged as React.ReactElement); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + }); + + it('does not fire the component action when the merged element is disabled', async () => { + const user = userEvent.setup(); + const ownOnClick = vi.fn(); + + const merged = mergeRenderProp(, { + disabled: false, + onClick: ownOnClick, + }); + + render(merged as React.ReactElement); + await user.click(screen.getByRole('button', { name: 'Add' })); + expect(ownOnClick).not.toHaveBeenCalled(); + }); + + it('returns null and dev-warns for a Fragment (would silently drop behavior)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const merged = mergeRenderProp( + ( + <> + iconAdd + + ) as React.ReactElement, + { onClick: vi.fn() }, + ); + + expect(merged).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('returns null and dev-warns for a non-element (string) instead of throwing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const merged = mergeRenderProp('Add' as unknown as React.ReactElement, { + onClick: vi.fn(), + }); + + expect(merged).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/lib/composability/create-component-context.tsx b/packages/react/src/lib/composability/create-component-context.tsx new file mode 100644 index 000000000..d76824abf --- /dev/null +++ b/packages/react/src/lib/composability/create-component-context.tsx @@ -0,0 +1,147 @@ +/** + * Factory for a typed compound-component context plus a guard hook. + * + * Each composable block component (SsoProviderTable, DomainTable, ...) creates + * one context that shares its model/props from the `Root` boundary down to the + * compound sub-components (`Header`, `Content`, `CreateAction`, ...). + * + * The factory also returns a small dev-tooling bundle (`parts`) for warning, + * in development only, when a host composes `Root` but omits a part the + * component needs to render (e.g. `Content`). See {@link createComponentContext}. + * + * @module create-component-context + * @internal + */ + +import * as React from 'react'; + +/** Registry shared from a `Root`'s {@link CompoundParts.Boundary} to its parts. */ +interface PartRegistry { + /** Marks a part present; returns a cleanup that removes it on unmount. */ + register: (partName: string) => () => void; +} + +/** Dev-tooling for the missing-required-part warning, returned by the factory. */ +export interface CompoundParts { + /** + * Provider that `Root` wraps its children in. Tracks which required parts + * mounted and, in development only, warns after mount for any that are + * missing. A passthrough (no warning) in production builds. + */ + Boundary: React.FC<{ children?: React.ReactNode }>; + /** + * Marks a compound part present for the missing-required-part check. Call + * once, unconditionally, inside a required part (e.g. `Content`). + * @param partName - The part's short name (e.g. `'Content'`), matched against + * the `requiredParts` passed to {@link createComponentContext}. + */ + useRegisterPart: (partName: string) => void; +} + +/** Options for {@link createComponentContext}. */ +export interface CreateComponentContextOptions { + /** + * Parts a host must render inside `Root` for the component to work (e.g. + * `['Content']`). A dev-only warning fires when one never mounts. Omit for + * components with no strictly-required part. + */ + requiredParts?: readonly string[]; +} + +/** + * Creates a context + guard hook pair for a compound component, plus dev tooling + * for the missing-required-part warning. + * + * @template T - Shape of the context value shared by the compound parts. + * @param displayName - Component display name, used for the context label, the + * error thrown when a part is rendered outside its `Root`, and the missing-part + * warning copy. + * @param options - See {@link CreateComponentContextOptions}. + * @returns A tuple `[Context, useContext, parts]`. The hook throws a descriptive + * error when a compound part is rendered outside `<${displayName}.Root>`. + * `parts` ({@link CompoundParts}) carries the dev-only missing-part tooling. + */ +export function createComponentContext( + displayName: string, + options?: CreateComponentContextOptions, +) { + const Context = React.createContext(null); + Context.displayName = `${displayName}Context`; + + const requiredParts = options?.requiredParts ?? []; + + // Internal registry context: `Boundary` (rendered by `Root`) provides a + // `register` callback; required parts call it from an effect so `Boundary` + // can warn about parts that never mounted. Kept separate from `Context` so it + // never widens the public composition value `T`. + const RegistryContext = React.createContext(null); + + /** + * Reads the shared context value provided by `Root`. + * @returns The context value of type `T`. + * @throws When rendered outside the component's `Root` provider. + */ + function useComponentContext(): T { + const value = React.useContext(Context); + if (value === null) { + throw new Error( + `${displayName} compound parts must be rendered inside <${displayName}.Root>.`, + ); + } + return value; + } + + /** + * Marks a compound part present for the missing-required-part check. + * @param partName - The part's short name (e.g. `'Content'`). + */ + function useRegisterPart(partName: string): void { + const registry = React.useContext(RegistryContext); + // Register on mount, unregister on unmount. React flushes descendant effects + // before ancestor effects, so `Boundary`'s check (below) always sees the set + // populated on first mount. Symmetric add/remove keeps StrictMode correct. + React.useEffect(() => registry?.register(partName), [registry, partName]); + } + + const Boundary: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + const mountedRef = React.useRef>(new Set()); + const registry = React.useMemo( + () => ({ + register(partName) { + mountedRef.current.add(partName); + return () => { + mountedRef.current.delete(partName); + }; + }, + }), + [], + ); + + React.useEffect(() => { + // Dev-only: consumer bundlers replace this with `false` in production + // builds and drop the block, so there is no prod overhead or console noise. + if (process.env.NODE_ENV === 'production' || requiredParts.length === 0) { + return; + } + const missing = requiredParts.filter((part) => !mountedRef.current.has(part)); + if (missing.length === 0) { + return; + } + const plural = missing.length > 1; + console.warn( + `🚨 [Auth0 Components Warning]: <${displayName}.Root> is missing required ` + + `part${plural ? 's' : ''}: ${missing.map((part) => `<${displayName}.${part}>`).join(', ')}. ` + + `The component will not render correctly. Add ${plural ? 'them' : 'it'} inside ` + + `Root, or use <${displayName}.DefaultLayout /> for the default anatomy.`, + ); + }, []); + + return {children}; + }; + + Boundary.displayName = `${displayName}.Boundary`; + + const parts: CompoundParts = { Boundary, useRegisterPart }; + + return [Context, useComponentContext, parts] as const; +} diff --git a/packages/react/src/lib/composability/index.ts b/packages/react/src/lib/composability/index.ts new file mode 100644 index 000000000..b208e30a9 --- /dev/null +++ b/packages/react/src/lib/composability/index.ts @@ -0,0 +1,12 @@ +/** + * Shared primitives for progressive composability across block components. + * @module composability + * @internal + */ + +export { + createComponentContext, + type CompoundParts, + type CreateComponentContextOptions, +} from './create-component-context'; +export { mergeRenderProp, type RenderPropOwnProps } from './render-prop'; diff --git a/packages/react/src/lib/composability/render-prop.tsx b/packages/react/src/lib/composability/render-prop.tsx new file mode 100644 index 000000000..b97b2c02b --- /dev/null +++ b/packages/react/src/lib/composability/render-prop.tsx @@ -0,0 +1,81 @@ +/** + * Element-replacement helper for compound action parts. + * + * Merges a host-supplied element (the `render` prop on parts like + * `CreateAction`) with the behavioral props the component owns. We use + * `React.cloneElement` with explicit prop merging rather than a Radix-style + * `asChild`/`Slot`, because host elements (`ProductButton`, `Link`, ...) do not + * reliably forward refs or accept arbitrary DOM props. + * + * Merge rules: + * - `disabled` — disabled when EITHER the host element or the component sets it + * (union, not "component wins": a host that passes `disabled` is honored). + * - Other behavioral props (`type`, `aria-*`, `ref`) — component wins. + * - `onClick` — chained: the host handler runs first; the component action is + * skipped when the host calls `event.preventDefault()` or when disabled. + * - Everything else (`className`, `data-*`, children, ...) — host wins. + * + * The host `render` must be a single, valid, non-Fragment element (Fragments + * silently drop the injected behavioral props → a dead button; strings/arrays + * throw in `cloneElement`). Invalid input warns and returns `null` so the + * caller falls back to its own default element. + * + * @module render-prop + * @internal + */ + +import * as React from 'react'; + +/** Behavioral props a compound action part contributes to its rendered element. */ +export interface RenderPropOwnProps { + disabled?: boolean; + type?: 'button' | 'submit' | 'reset'; + 'aria-label'?: string; + ref?: React.Ref; + onClick?: (event: React.MouseEvent) => void; + [key: string]: unknown; +} + +/** + * Clones `render` and merges the component's own behavioral props onto it. + * + * @param render - Host-supplied element used to replace the default leaf. + * @param ownProps - Behavioral props owned by the compound part. + * @returns The cloned element with merged props, or `null` when `render` is not + * a single valid non-Fragment element (caller should render its default). + */ +export function mergeRenderProp( + render: React.ReactElement, + ownProps: RenderPropOwnProps, +): React.ReactElement | null { + if (!React.isValidElement(render) || render.type === React.Fragment) { + console.warn( + '🚨 [Auth0 Components Warning]: The `render` prop must be a single valid ' + + 'host element (not a Fragment, string, number, or array). Falling back to ' + + 'the default action. Wrap multiple nodes in one element, e.g. ' + + '`render={}`.', + ); + return null; + } + + const hostProps = (render.props ?? {}) as RenderPropOwnProps; + const { onClick: ownOnClick, disabled: ownDisabled, ...restOwnProps } = ownProps; + const hostOnClick = hostProps.onClick; + + // Disabled if EITHER side sets it. Host elements (Link, custom buttons) may + // not honor a native `disabled`, so gate the component action on this too. + const mergedDisabled = Boolean(ownDisabled) || Boolean(hostProps.disabled); + + const mergedOnClick = (event: React.MouseEvent) => { + hostOnClick?.(event); + if (!event.defaultPrevented && !mergedDisabled) { + ownOnClick?.(event); + } + }; + + return React.cloneElement(render, { + ...restOwnProps, + disabled: mergedDisabled, + onClick: mergedOnClick, + } as Partial & React.Attributes); +} diff --git a/packages/react/src/tests/utils/__mocks__/my-organization/domain-management/domain.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/domain-management/domain.mocks.ts index e46f4c456..0ebf3545c 100644 --- a/packages/react/src/tests/utils/__mocks__/my-organization/domain-management/domain.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-organization/domain-management/domain.mocks.ts @@ -10,7 +10,7 @@ import { ALL_DOMAIN_PERMISSIONS } from '@/tests/utils/__mocks__/permissions/perm import type { DomainTableActionsColumnProps, DomainTableProps, - UseDomainTableReturn, + UseDomainTableResult, UseDomainTableServiceOptions, UseDomainTableServiceReturn, } from '@/types/my-organization/domain-management/domain-table-types'; @@ -134,8 +134,8 @@ export const createMockDeleteAction = (): ComponentAction => ({ }); export const createMockDomainTableReturn = ( - overrides: Partial = {}, -): UseDomainTableReturn => ({ + overrides: Partial = {}, +): UseDomainTableResult => ({ permissions: ALL_DOMAIN_PERMISSIONS, domains: [createMockDomain(), createMockVerifiedDomain()], providers: [], diff --git a/packages/react/src/types/my-account/user-mfa-management/user-mfa-management-types.ts b/packages/react/src/types/my-account/user-mfa-management/user-mfa-management-types.ts index a0462b57a..b1a8255aa 100644 --- a/packages/react/src/types/my-account/user-mfa-management/user-mfa-management-types.ts +++ b/packages/react/src/types/my-account/user-mfa-management/user-mfa-management-types.ts @@ -104,7 +104,7 @@ export interface UseUserMFAOptions { } /** Return type of useUserMFA hook. */ -export interface UseUserMFAReturn { +export interface UseUserMFAResult { factorsByType: Record; isLoadingFactors: boolean; isEnrolling: boolean; @@ -133,6 +133,12 @@ export interface UseUserMFAReturn { handleEnterQRPhase: () => Promise; } +/** + * @deprecated Use {@link UseUserMFAResult} instead. This alias is kept for one + * release and will be removed in the next major version. + */ +export type UseUserMFAReturn = UseUserMFAResult; + /** Return type of useUserMFAService hook. */ export interface UseUserMFAServiceReturn { factorsQuery: UseQueryResult>; diff --git a/packages/react/src/types/my-account/user-passkey-management/user-passkey-management-types.ts b/packages/react/src/types/my-account/user-passkey-management/user-passkey-management-types.ts index 080f15993..2d4d38ce4 100644 --- a/packages/react/src/types/my-account/user-passkey-management/user-passkey-management-types.ts +++ b/packages/react/src/types/my-account/user-passkey-management/user-passkey-management-types.ts @@ -53,6 +53,13 @@ export interface UserPasskeyManagementViewProps { customMessages: UserPasskeyManagementProps['customMessages']; hideHeader: boolean; disableAdd: boolean; + /** + * Suppress the in-card Add button independently of `disableAdd`. Defaults to + * `false` (Tier-1 renders it). The composability layer sets this to `true` when + * a standalone `UserPasskeyManagement.AddAction` is mounted, so the two Add + * controls do not both render. + */ + hideAddButton?: boolean; disableRevoke: boolean; isRevokeModalOpen: boolean; currentPasskey: Passkey | null; @@ -69,7 +76,7 @@ export interface UseUserPasskeyOptions { onFetch?: () => void; } -export interface UseUserPasskeyReturn { +export interface UseUserPasskeyResult { passkeys: Passkey[]; isLoading: boolean; isEnrolling: boolean; @@ -85,6 +92,12 @@ export interface UseUserPasskeyReturn { handleConfirmRevoke: () => Promise; } +/** + * @deprecated Use {@link UseUserPasskeyResult} instead. This alias is kept for + * one release and will be removed in the next major version. + */ +export type UseUserPasskeyReturn = UseUserPasskeyResult; + export interface UseUserPasskeyServiceResult { passkeysQuery: UseQueryResult; enrollMutation: UseMutationResult; diff --git a/packages/react/src/types/my-organization/domain-management/domain-table-types.ts b/packages/react/src/types/my-organization/domain-management/domain-table-types.ts index 1c9212355..34fbdbdce 100644 --- a/packages/react/src/types/my-organization/domain-management/domain-table-types.ts +++ b/packages/react/src/types/my-organization/domain-management/domain-table-types.ts @@ -131,7 +131,7 @@ export interface UseDomainTableServiceReturn { } /** Return type for the public domain table hook. */ -export interface UseDomainTableReturn { +export interface UseDomainTableResult { permissions: DomainManagementPermissions; // Data @@ -184,12 +184,19 @@ export interface UseDomainTableReturn { handlePageSizeChange: (pageSize: number) => void; } +/** + * @deprecated Use {@link UseDomainTableResult} instead. This alias is kept for + * one release and will be removed in the next major version. + */ +export type UseDomainTableReturn = UseDomainTableResult; + /** Props for the DomainTableView presentational component. @internal */ export interface DomainTableViewProps { - domainTable: UseDomainTableReturn; + domainTable: UseDomainTableResult; schema: DomainTableProps['schema']; styling: DomainTableProps['styling']; hideHeader: DomainTableProps['hideHeader']; + hideRefresh?: boolean; readOnly: DomainTableProps['readOnly']; customMessages: DomainTableProps['customMessages']; createAction: DomainTableProps['createAction']; diff --git a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts index e648efa3b..6d9b4eb2f 100644 --- a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts +++ b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-edit-types.ts @@ -139,7 +139,7 @@ export interface UseSsoProviderEditServiceReturn { onRemoveConfirm: () => Promise; } -export interface UseSsoProviderEditReturn extends UseSsoProviderEditServiceReturn { +export interface UseSsoProviderEditResult extends UseSsoProviderEditServiceReturn { permissions: IdpManagementPermissions; shouldAllowDeletion: boolean; isLoadingConfig: boolean; @@ -153,6 +153,12 @@ export interface UseSsoProviderEditReturn extends UseSsoProviderEditServiceRetur handleToggleProvider: (enabled: boolean) => Promise; } +/** + * @deprecated Use {@link UseSsoProviderEditResult} instead. This alias is kept + * for one release and will be removed in the next major version. + */ +export type UseSsoProviderEditReturn = UseSsoProviderEditResult; + export interface SsoProviderAttributeMappingsProps extends SharedComponentProps { userAttributeMap: IdpProvisioningUserAttributeMapItem[] | IdpUserAttributeMapItem[] | null; @@ -173,7 +179,7 @@ export interface SsoProviderAttributeSyncAlertProps { } export interface SsoProviderEditViewProps - extends UseSsoProviderEditReturn, + extends UseSsoProviderEditResult, Pick< SsoProviderEditProps, | 'styling' diff --git a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts index deb0cdeea..e7fd67cf2 100644 --- a/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts +++ b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts @@ -84,7 +84,7 @@ export interface UseSsoProviderTableOptions { } /** useSsoProviderTable hook result. */ -export interface UseSsoProviderTableReturn { +export interface UseSsoProviderTableResult { permissions: IdpManagementPermissions; providers: IdpKnownResponse[]; organization: OrganizationPrivate | null; @@ -123,6 +123,12 @@ export interface UseSsoProviderTableReturn { setSelectedIdp: React.Dispatch>; } +/** + * @deprecated Use {@link UseSsoProviderTableResult} instead. This alias is kept + * for one release and will be removed in the next major version. + */ +export type UseSsoProviderTableReturn = UseSsoProviderTableResult; + /** Props for SsoProviderTable actions column. */ export interface SsoProviderTableActionsColumnProps extends SharedComponentProps< @@ -146,11 +152,14 @@ export interface SsoProviderTableActionsColumnProps onDelete: (provider: IdpKnownResponse) => void; onRemoveFromOrganization: (provider: IdpKnownResponse) => void; } -export interface SsoProviderTableViewProps extends UseSsoProviderTableReturn { +export interface SsoProviderTableViewProps extends UseSsoProviderTableResult { styling: SsoProviderTableProps['styling']; customMessages: SsoProviderTableProps['customMessages']; readOnly: SsoProviderTableProps['readOnly']; hideHeader: boolean; + /** When true, the inline refresh control is not rendered by the view. Used by + * compound composition, where `SsoProviderTable.Refresh` places it instead. */ + hideRefresh?: boolean; hideDeleteProvider?: boolean; hideRemoveFromOrganization?: boolean; createAction: SsoProviderTableProps['createAction']; diff --git a/packages/react/src/types/my-organization/member-management/organization-member-detail-types.ts b/packages/react/src/types/my-organization/member-management/organization-member-detail-types.ts index 66eee5ca4..c9ab81968 100644 --- a/packages/react/src/types/my-organization/member-management/organization-member-detail-types.ts +++ b/packages/react/src/types/my-organization/member-management/organization-member-detail-types.ts @@ -262,6 +262,12 @@ export interface OrganizationMemberDetailProps export interface OrganizationMemberDetailViewProps extends UseOrganizationMemberDetailResult { styling: OrganizationMemberDetailProps['styling']; customMessages: OrganizationMemberDetailProps['customMessages']; + /** + * Suppress the built-in avatar/back header. Defaults to `false` (Tier-1 renders + * it). The composability layer sets this to `true` so its standalone + * `OrganizationMemberDetail.Header` part owns the header instead. + */ + hideHeader?: boolean; } export type MemberDetailHeaderProps = Pick< 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 index 2db689376..64fc4da36 100644 --- 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 @@ -226,6 +226,8 @@ export interface OrganizationMemberManagementViewProps customMessages: OrganizationMemberManagementProps['customMessages']; hideHeader: boolean; readOnly: boolean; + /** When true, suppresses the built-in refresh indicator (owned by the composable `Refresh` part). */ + hideRefresh?: boolean; } /** CSS classes for OrganizationMemberManagement. */