From 220b41f5494839503ad4cfdb392659f5db1ac95b Mon Sep 17 00:00:00 2001 From: harish-sundar_akto Date: Fri, 4 Sep 2026 12:06:11 +0530 Subject: [PATCH 01/11] feat(react): add progressive composability layer for components --- .../user-mfa-management.composable.test.tsx | 128 +++++++ ...ser-passkey-management.composable.test.tsx | 137 +++++++ .../user-mfa-management.composable.tsx | 248 +++++++++++++ .../user-passkey-management.composable.tsx | 277 ++++++++++++++ .../domain-table.composable.test.tsx | 148 ++++++++ ...anization-details-edit.composable.test.tsx | 110 ++++++ ...nization-member-detail.composable.test.tsx | 88 +++++ ...tion-member-management.composable.test.tsx | 193 ++++++++++ .../sso-provider-create.composable.test.tsx | 148 ++++++++ .../sso-provider-edit.composable.test.tsx | 105 ++++++ .../sso-provider-table.composable.test.tsx | 194 ++++++++++ .../domain-table.composable.tsx | 309 ++++++++++++++++ .../auth0/my-organization/domain-table.tsx | 26 +- .../organization-details-edit.composable.tsx | 226 ++++++++++++ .../organization-member-detail.composable.tsx | 191 ++++++++++ ...anization-member-management.composable.tsx | 340 ++++++++++++++++++ .../organization-member-management.tsx | 15 +- .../sso-provider-create.composable.tsx | 242 +++++++++++++ .../sso-provider-edit.composable.tsx | 289 +++++++++++++++ .../sso-provider-table.composable.tsx | 315 ++++++++++++++++ .../my-organization/sso-provider-table.tsx | 29 +- .../src/components/auth0/shared/header.tsx | 155 ++++---- packages/react/src/index.ts | 68 ++-- .../create-component-context.tsx | 43 +++ packages/react/src/lib/composability/index.ts | 8 + .../src/lib/composability/render-prop.tsx | 59 +++ .../domain-management/domain-table-types.ts | 1 + .../sso-provider/sso-provider-table-types.ts | 3 + .../organization-member-management-types.ts | 2 + 29 files changed, 3962 insertions(+), 135 deletions(-) create mode 100644 packages/react/src/components/auth0/my-account/__tests__/user-mfa-management.composable.test.tsx create mode 100644 packages/react/src/components/auth0/my-account/__tests__/user-passkey-management.composable.test.tsx create mode 100644 packages/react/src/components/auth0/my-account/user-mfa-management.composable.tsx create mode 100644 packages/react/src/components/auth0/my-account/user-passkey-management.composable.tsx create mode 100644 packages/react/src/components/auth0/my-organization/__tests__/domain-table.composable.test.tsx create mode 100644 packages/react/src/components/auth0/my-organization/__tests__/organization-details-edit.composable.test.tsx create mode 100644 packages/react/src/components/auth0/my-organization/__tests__/organization-member-detail.composable.test.tsx create mode 100644 packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.composable.test.tsx create mode 100644 packages/react/src/components/auth0/my-organization/__tests__/sso-provider-create.composable.test.tsx create mode 100644 packages/react/src/components/auth0/my-organization/__tests__/sso-provider-edit.composable.test.tsx create mode 100644 packages/react/src/components/auth0/my-organization/__tests__/sso-provider-table.composable.test.tsx create mode 100644 packages/react/src/components/auth0/my-organization/domain-table.composable.tsx create mode 100644 packages/react/src/components/auth0/my-organization/organization-details-edit.composable.tsx create mode 100644 packages/react/src/components/auth0/my-organization/organization-member-detail.composable.tsx create mode 100644 packages/react/src/components/auth0/my-organization/organization-member-management.composable.tsx create mode 100644 packages/react/src/components/auth0/my-organization/sso-provider-create.composable.tsx create mode 100644 packages/react/src/components/auth0/my-organization/sso-provider-edit.composable.tsx create mode 100644 packages/react/src/components/auth0/my-organization/sso-provider-table.composable.tsx create mode 100644 packages/react/src/lib/composability/create-component-context.tsx create mode 100644 packages/react/src/lib/composability/index.ts create mode 100644 packages/react/src/lib/composability/render-prop.tsx 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..fdc34ec34 --- /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 banner/title; Content + // suppresses its own header (hideHeader). + expect(screen.getAllByRole('banner')).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-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..2854df5cc --- /dev/null +++ b/packages/react/src/components/auth0/my-account/__tests__/user-passkey-management.composable.test.tsx @@ -0,0 +1,137 @@ +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('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/user-mfa-management.composable.tsx b/packages/react/src/components/auth0/my-account/user-mfa-management.composable.tsx new file mode 100644 index 000000000..e751f8ab5 --- /dev/null +++ b/packages/react/src/components/auth0/my-account/user-mfa-management.composable.tsx @@ -0,0 +1,248 @@ +/** + * 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, + UseUserMFAReturn, +} from '@/types/my-account/user-mfa-management/user-mfa-management-types'; + +/** Value shared from `Root` to every compound part. */ +interface UserMFAManagementComposition { + model: UseUserMFAReturn; + props: UserMFAManagementProps; +} + +const [UserMFAManagementContext, useUserMFAManagementContext] = + createComponentContext('UserMFAManagement'); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** 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, ...props }: UserMFAManagementRootProps) { + useTelemetry('user-mfa-management'); + + const { + customMessages = {}, + styling = DEFAULT_STYLING, + showActiveOnly = false, + disableDelete = false, + readOnly = false, + factorConfig = {}, + enrollAction, + deleteAction, + } = props; + + const model = useUserMFA({ + showActiveOnly, + readOnly, + disableDelete, + factorConfig, + customMessages, + enrollAction, + deleteAction, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + const composition = React.useMemo( + () => ({ model, props: { ...props, styling, customMessages } }), + [model, props, styling, customMessages], + ); + + 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(); + 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() { + return ( + <> + + + + ); +} + +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..4dbb98c50 --- /dev/null +++ b/packages/react/src/components/auth0/my-account/user-passkey-management.composable.tsx @@ -0,0 +1,277 @@ +/** + * 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. Rendering `AddAction` alongside the default `Content` will + * surface TWO add controls — hosts composing their own structure should pass + * `hideHeader`-style discipline and avoid duplicating the trigger. + * + * @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, + UseUserPasskeyReturn, +} from '@/types/my-account/user-passkey-management/user-passkey-management-types'; + +/** Value shared from `Root` to every compound part. */ +interface UserPasskeyManagementComposition { + model: UseUserPasskeyReturn; + props: UserPasskeyManagementProps; +} + +const [UserPasskeyManagementContext, useUserPasskeyManagementContext] = + createComponentContext('UserPasskeyManagement'); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** 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, ...props }: UserPasskeyManagementRootProps) { + useTelemetry('user-passkey-management'); + + const { + customMessages = {}, + styling = DEFAULT_STYLING, + hideHeader = false, + addAction, + revokeAction, + onFetch, + } = props; + + const model = useUserPasskey({ + customMessages, + addAction, + revokeAction, + onFetch, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + const composition = React.useMemo( + () => ({ model, props: { ...props, styling, customMessages, hideHeader } }), + [model, props, styling, customMessages, hideHeader], + ); + + return ( + + + {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); + + 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. + return mergeRenderProp(render, { type: 'button', disabled, onClick }); + } + + 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'; + +/** + * 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. + * @returns The passkey content. + */ +function Content() { + const { model, props } = useUserPasskeyManagementContext(); + 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-organization/__tests__/domain-table.composable.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/domain-table.composable.test.tsx new file mode 100644 index 000000000..6271ce47e --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/domain-table.composable.test.tsx @@ -0,0 +1,148 @@ +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 { 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'; + +mockToast(); +const { initMockCoreClient } = mockCore(); + +const waitForComponentToLoad = async () => + waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument()); + +describe('DomainTable — composability', () => { + 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(); + }); + + describe('Tier 1 — default (backwards compatible)', () => { + it('renders the table and create button when called directly', async () => { + renderWithProviders(); + await waitForComponentToLoad(); + + 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(); + + renderWithProviders( + + + + Add domain + + } + /> + + , + ); + 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__/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-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..c39926ab0 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-detail.composable.test.tsx @@ -0,0 +1,88 @@ +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 would render a second header/back button. Parts read + // the single model provided by Root via context. + expect(screen.getAllByText('member.detail.back_button')).toHaveLength(1); + expect(screen.getByRole('heading', { name: mockMember.name! })).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-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__/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-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-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..fafa95d90 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/__tests__/sso-provider-table.composable.test.tsx @@ -0,0 +1,194 @@ +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(); + }); + }); +}); 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..2981c8b78 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/domain-table.composable.tsx @@ -0,0 +1,309 @@ +/** + * 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 { 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, + UseDomainTableReturn, +} from '@/types/my-organization/domain-management/domain-table-types'; + +/** Value shared from `Root` to every compound part. */ +interface DomainTableComposition { + model: UseDomainTableReturn; + props: DomainTableProps; +} + +const [DomainTableContext, useDomainTableContext] = + createComponentContext('DomainTable'); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** 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, ...props }: DomainTableRootProps) { + useTelemetry('domain-management'); + + const { + customMessages = {}, + styling = DEFAULT_STYLING, + readOnly = false, + createAction, + verifyAction, + deleteAction, + associateToProviderAction, + deleteFromProviderAction, + } = props; + + const model = useDomainTable({ + createAction, + verifyAction, + deleteAction, + associateToProviderAction, + deleteFromProviderAction, + customMessages, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + const composition = React.useMemo( + () => ({ model, props: { ...props, styling, readOnly, customMessages } }), + [model, props, styling, readOnly, customMessages], + ); + + 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 disabled = + Boolean(props.createAction?.disabled) || Boolean(props.readOnly) || model.isFetching; + const onClick = () => model.handleCreateClick(); + + if (render) { + // The host element owns its own label/content; we only wire behavior. + return mergeRenderProp(render, { type: 'button', disabled, onClick }); + } + + 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(); + 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 }) { + return ( + <> + + + + + ); +} + +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 146a453ee..139480399 100644 --- a/packages/react/src/components/auth0/my-organization/domain-table.tsx +++ b/packages/react/src/components/auth0/my-organization/domain-table.tsx @@ -89,6 +89,7 @@ function DomainTableView({ schema, styling, hideHeader, + hideRefresh, readOnly = false, customMessages, createAction, @@ -201,16 +202,21 @@ 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 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] = + createComponentContext('OrganizationDetailsEdit'); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** 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, ...props }: OrganizationDetailsEditRootProps) { + useTelemetry('organization-details'); + + const { + saveAction, + cancelAction, + readOnly = false, + customMessages = {}, + styling = DEFAULT_STYLING, + } = props; + + const model = useOrganizationDetailsEdit({ + saveAction, + cancelAction, + readOnly, + customMessages, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + const composition = React.useMemo( + () => ({ model, props: { ...props, styling, readOnly, customMessages } }), + [model, props, styling, readOnly, customMessages], + ); + + 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 ( +
+
+
+ ); +} + +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(); + 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() { + return ( + <> + + + + ); +} + +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..bef6284a0 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/organization-member-detail.composable.tsx @@ -0,0 +1,191 @@ +/** + * Progressive-composability layer for {@link OrganizationMemberDetail}. + * + * 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): `Root` + `Content`, 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 `Header`/`Refresh` parts: + * Unlike the {@link SsoProviderTable} pilot, this component does NOT use the + * shared {@link Header} with a separable action region. It renders a LOCAL, + * name-driven avatar/back-button header inside {@link OrganizationMemberDetailView} + * (see `Header` in `organization-member-detail.tsx`). Consequences: + * - There is no shared-Header action slot to replace, so no `Header` part. + * - `hideHeader` on the props type is not honored by the view, so it is not + * surfaced here. + * - There is no refresh/last-updated affordance, so no `Refresh` part and no + * `hideRefresh`. + * - The 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. + * As a result this layer is intentionally headless-leaning: it offers Root/Content + * structural wrapping (Tier 3) for interleaving host UI, and the Tier-4 model hook + * for full control. Finer-grained composition requires the Tier-4 hook. + * + * @module organization-member-detail.composable + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import * as React from 'react'; + +import { + OrganizationMemberDetail as OrganizationMemberDetailDefault, + 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] = + createComponentContext('OrganizationMemberDetail'); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** 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, ...props }: OrganizationMemberDetailRootProps) { + const { + userId, + onBack, + customMessages = {}, + styling = DEFAULT_STYLING, + initialTab, + removeFromOrganizationAction, + assignRolesAction, + removeRolesAction, + } = props; + + const model = useOrganizationMemberDetail({ + userId, + onBack, + customMessages, + initialTab, + removeFromOrganizationAction, + assignRolesAction, + removeRolesAction, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + const composition = React.useMemo( + () => ({ model, props: { ...props, styling, customMessages } }), + [model, props, styling, customMessages], + ); + + return ( + + + {children} + + + ); +} + +Root.displayName = 'OrganizationMemberDetail.Root'; + +/** + * The full member detail body (local header + tabs + modals). Reuses the + * existing view with an EXACT replication of the Tier-1 container's prop + * pass-through (spread of the shared model plus `styling` and `customMessages`). + * + * The view owns its local avatar/back-button header, so `Content` renders the + * complete detail including that header — there is no separate `Header` part + * (see the module HURDLE note). + * @returns The member detail content. + */ +function Content() { + const { model, props } = useOrganizationMemberDetailContext(); + return ( + + ); +} + +Content.displayName = 'OrganizationMemberDetail.Content'; + +/** + * The default anatomy: just the full detail 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 = 'OrganizationMemberDetail.DefaultLayout'; + +/** + * Organization member detail with progressive composability. + * + * Callable directly for the Tier-1 default (``), + * and exposes `Root`/`Content` for structural (Tier 3) composition — interleaving + * host UI around the detail. For fully headless (Tier 4) usage, see + * `useOrganizationMemberDetailModel`. + * + * This component has no shared-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, + Content, +}); + +export { + OrganizationMemberDetail, + Root, + DefaultLayout, + Content, + useOrganizationMemberDetailContext, +}; 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..78b587daf --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/organization-member-management.composable.tsx @@ -0,0 +1,340 @@ +/** + * 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 { 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] = + createComponentContext('OrganizationMemberManagement'); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** 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, ...props }: OrganizationMemberManagementRootProps) { + const { + customMessages = {}, + styling = DEFAULT_STYLING, + readOnly = false, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + viewMemberDetailsAction, + assignRolesAction, + removeFromOrganizationAction, + } = props; + + const model = useOrganizationMemberManagement({ + customMessages, + readOnly, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + viewMemberDetailsAction, + assignRolesAction, + removeFromOrganizationAction, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + const composition = React.useMemo( + () => ({ model, props: { ...props, styling, readOnly, customMessages } }), + [model, props, styling, readOnly, customMessages], + ); + + 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 disabled = Boolean(props.readOnly); + const onClick = () => model.openModal({ type: 'create' }); + + if (render) { + // The host element owns its own label/content; we only wire behavior. + return mergeRenderProp(render, { type: 'button', disabled, onClick }); + } + + 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(); + 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 ( + <> + + + + ); +} + +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 abadf66ee..ff46422a1 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 @@ -44,6 +44,7 @@ export function OrganizationMemberManagementView(props: OrganizationMemberManage styling, customMessages, hideHeader, + hideRefresh, readOnly, activeTab, members, @@ -192,12 +193,14 @@ 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..554113af3 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/sso-provider-create.composable.tsx @@ -0,0 +1,242 @@ +/** + * 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`.** 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`. + * 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. **Structured `{ logic, handlers }` bundles.** The view consumes two typed + * bundles rather than a flat prop spread, so `Content` reconstructs + * `ssoProviderCreateLogicProps` and `ssoProviderCreateHandlerProps` exactly + * as the container does before rendering the view. + * + * 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 { + SsoProviderCreateHandlerProps, + SsoProviderCreateLogicProps, + 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] = + createComponentContext('SsoProviderCreate'); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** 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, ...props }: SsoProviderCreateRootProps) { + useTelemetry('sso-create-configuration'); + + const { + createAction, + customMessages = {}, + styling = DEFAULT_STYLING, + onNext, + onPrevious, + } = props; + + const model = useSsoProviderCreate({ + createAction, + customMessages, + onNext, + onPrevious, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + const composition = React.useMemo( + () => ({ model, props: { ...props, styling, customMessages } }), + [model, props, styling, customMessages], + ); + + 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(); + + const { + formData, + detailsRef, + configureRef, + setFormData, + handleCreate, + createStepActions, + isCreating, + isLoadingConfig, + filteredStrategies, + isLoadingIdpConfig, + idpConfig, + showThirdPartyAccess, + showCrossAppAccess, + isCrossAppAccessReadOnly, + isOrganizationBlocked, + } = model; + + const { onNext, onPrevious, backButton } = props; + const { strategy, details, configure } = formData; + + const ssoProviderCreateLogicProps: SsoProviderCreateLogicProps = { + formData, + strategy, + details, + configure, + isCreating, + isLoadingConfig, + filteredStrategies, + isLoadingIdpConfig, + idpConfig, + showThirdPartyAccess, + showCrossAppAccess, + isCrossAppAccessReadOnly, + isOrganizationBlocked, + styling: props.styling ?? DEFAULT_STYLING, + customMessages: props.customMessages, + backButton, + }; + + const ssoProviderCreateHandlerProps: SsoProviderCreateHandlerProps = { + onNext, + onPrevious, + setFormData, + detailsRef, + configureRef, + handleCreate, + createStepActions, + }; + + 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..85df53aaf --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/sso-provider-edit.composable.tsx @@ -0,0 +1,289 @@ +/** + * 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, + UseSsoProviderEditReturn, +} from '@/types/my-organization/idp-management/sso-provider/sso-provider-edit-types'; + +/** Value shared from `Root` to every compound part. */ +interface SsoProviderEditComposition { + model: UseSsoProviderEditReturn; + /** Normalized props (defaults applied) shared with every part. */ + props: SsoProviderEditProps; +} + +const [SsoProviderEditContext, useSsoProviderEditContext] = + createComponentContext('SsoProviderEdit'); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** 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, ...props }: SsoProviderEditRootProps) { + useTelemetry('sso-edit-configuration'); + + const { + providerId, + sso, + provisioning, + domains, + hideHeader = false, + hideProvisioningTab = false, + hideDeleteProvider = false, + hideRemoveFromOrganization = false, + hideAttributeMappings = false, + customMessages = {}, + styling = DEFAULT_STYLING, + readOnly = false, + enableProviderAction, + } = props; + + 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], + ); + + const composition = React.useMemo( + () => ({ + model, + props: { + ...props, + styling, + readOnly, + customMessages, + hideHeader, + hideProvisioningTab, + hideDeleteProvider, + hideRemoveFromOrganization, + hideAttributeMappings, + }, + }), + [ + model, + props, + styling, + readOnly, + customMessages, + hideHeader, + hideProvisioningTab, + hideDeleteProvider, + hideRemoveFromOrganization, + hideAttributeMappings, + ], + ); + + 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); + + 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(); + 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() { + return ( + <> + + + + ); +} + +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-table.composable.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-table.composable.tsx new file mode 100644 index 000000000..46ca37739 --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/sso-provider-table.composable.tsx @@ -0,0 +1,315 @@ +/** + * 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 { 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, + UseSsoProviderTableReturn, +} from '@/types/my-organization/idp-management/sso-provider/sso-provider-table-types'; + +/** Value shared from `Root` to every compound part. */ +interface SsoProviderTableComposition { + model: UseSsoProviderTableReturn; + props: SsoProviderTableProps; +} + +const [SsoProviderTableContext, useSsoProviderTableContext] = + createComponentContext('SsoProviderTable'); + +const DEFAULT_STYLING: NonNullable = { + variables: { common: {}, light: {}, dark: {} }, + classes: {}, +}; + +/** 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, ...props }: SsoProviderTableRootProps) { + useTelemetry('sso-table-configuration'); + + const { + customMessages = {}, + styling = DEFAULT_STYLING, + readOnly = false, + createAction, + editAction, + deleteAction, + deleteFromOrganizationAction, + enableProviderAction, + } = props; + + const model = useSsoProviderTable({ + readOnly, + customMessages, + createAction, + editAction, + deleteAction, + deleteFromOrganizationAction, + enableProviderAction, + }); + + const { isDarkMode } = useTheme(); + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + const composition = React.useMemo( + () => ({ model, props: { ...props, styling, readOnly, customMessages } }), + [model, props, styling, readOnly, customMessages], + ); + + 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 disabled = Boolean(props.createAction?.disabled) || Boolean(props.readOnly); + const onClick = () => model.handleCreate(); + + if (render) { + // The host element owns its own label/content; we only wire behavior. + return mergeRenderProp(render, { type: 'button', disabled, onClick }); + } + + 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(); + 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 }) { + return ( + <> + + + + + ); +} + +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 7d7cf0ece..0ada9fa6e 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 @@ -98,6 +98,7 @@ function SsoProviderTableView({ customMessages, readOnly, hideHeader, + hideRefresh, hideDeleteProvider, hideRemoveFromOrganization, providers, @@ -221,19 +222,21 @@ function SsoProviderTableView({ )} -
- -
+ {!hideRefresh && ( +
+ +
+ )} ) => 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; } @@ -111,82 +114,88 @@ 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, ...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; + }; + + return ( +
+ {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/index.ts b/packages/react/src/index.ts index 78731ed0a..479c0fcaa 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -3,39 +3,30 @@ */ // 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'; // Shared hooks export { useCoreClient, CoreClientContext } from './hooks/shared/use-core-client'; @@ -47,21 +38,32 @@ export { useErrorHandler } from './hooks/shared/use-error-handler'; // My Account hooks export { useUserMFA } from './hooks/my-account/use-user-mfa'; export { useUserPasskey } from './hooks/my-account/use-user-passkey'; +// Tier-4 headless aliases — stable public names for the model hooks. +export { useUserMFA as useUserMFAModel } from './hooks/my-account/use-user-mfa'; +export { useUserPasskey as useUserPasskeyModel } from './hooks/my-account/use-user-passkey'; // 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 { useOrganizationDetailsEdit as useOrganizationDetailsEditModel } from './hooks/my-organization/use-organization-details-edit'; export { useDomainTable } from './hooks/my-organization/use-domain-table'; +export { useDomainTable as useDomainTableModel } from './hooks/my-organization/use-domain-table'; 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 { useSsoProviderCreate as useSsoProviderCreateModel } from './hooks/my-organization/use-sso-provider-create'; export { useSsoProviderEdit } from './hooks/my-organization/use-sso-provider-edit'; +export { useSsoProviderEdit as useSsoProviderEditModel } from './hooks/my-organization/use-sso-provider-edit'; export { useSsoProviderTable } from './hooks/my-organization/use-sso-provider-table'; +// Tier-4 headless alias — stable public name for the model hook. +export { useSsoProviderTable as useSsoProviderTableModel } from './hooks/my-organization/use-sso-provider-table'; // Member Management hooks export { useOrganizationMemberManagement } from './hooks/my-organization/use-organization-member-management'; +export { useOrganizationMemberManagement as useOrganizationMemberManagementModel } from './hooks/my-organization/use-organization-member-management'; export { useOrganizationMemberDetail } from './hooks/my-organization/use-member-detail'; +export { useOrganizationMemberDetail as useOrganizationMemberDetailModel } from './hooks/my-organization/use-member-detail'; // Auth types export * from './types/auth-types'; 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..63f3d0dd3 --- /dev/null +++ b/packages/react/src/lib/composability/create-component-context.tsx @@ -0,0 +1,43 @@ +/** + * 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`, ...). + * + * @module create-component-context + * @internal + */ + +import * as React from 'react'; + +/** + * Creates a context + guard hook pair for a compound component. + * + * @template T - Shape of the context value shared by the compound parts. + * @param displayName - Component display name, used for the context label and + * the error thrown when a part is rendered outside its `Root`. + * @returns A tuple `[Context, useContext]`. The hook throws a descriptive error + * when a compound part is rendered outside `<${displayName}.Root>`. + */ +export function createComponentContext(displayName: string) { + const Context = React.createContext(null); + Context.displayName = `${displayName}Context`; + + /** + * 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; + } + + return [Context, useComponentContext] 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..567a4633e --- /dev/null +++ b/packages/react/src/lib/composability/index.ts @@ -0,0 +1,8 @@ +/** + * Shared primitives for progressive composability across block components. + * @module composability + * @internal + */ + +export { createComponentContext } 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..16660df30 --- /dev/null +++ b/packages/react/src/lib/composability/render-prop.tsx @@ -0,0 +1,59 @@ +/** + * 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: + * - Behavioral props (`disabled`, `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. + * + * @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. + */ +export function mergeRenderProp( + render: React.ReactElement, + ownProps: RenderPropOwnProps, +): React.ReactElement { + const hostProps = (render.props ?? {}) as RenderPropOwnProps; + const { onClick: ownOnClick, disabled: ownDisabled, ...restOwnProps } = ownProps; + const hostOnClick = hostProps.onClick; + + const mergedOnClick = (event: React.MouseEvent) => { + hostOnClick?.(event); + if (!event.defaultPrevented && !ownDisabled) { + ownOnClick?.(event); + } + }; + + return React.cloneElement(render, { + ...restOwnProps, + disabled: ownDisabled ?? hostProps.disabled, + onClick: mergedOnClick, + } as Partial & React.Attributes); +} 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 8febd46a8..c31856b00 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 @@ -187,6 +187,7 @@ export interface DomainTableViewProps { 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-table-types.ts b/packages/react/src/types/my-organization/idp-management/sso-provider/sso-provider-table-types.ts index 10bfc1d37..53cc6895f 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 @@ -148,6 +148,9 @@ export interface SsoProviderTableViewProps extends UseSsoProviderTableReturn { customMessages: SsoProviderTableProps['customMessages']; readOnly: boolean; 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-management-types.ts b/packages/react/src/types/my-organization/member-management/organization-member-management-types.ts index 5da04693b..1eb875588 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 @@ -210,6 +210,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. */ From eb6e7a01f57caa444fdf0bb8613c37c1e9201f65 Mon Sep 17 00:00:00 2001 From: harish-sundar_akto Date: Mon, 28 Sep 2026 13:01:48 +0530 Subject: [PATCH 02/11] fix(react): honor hideheader and stabilize root context memo --- .../user-mfa-management.composable.tsx | 79 +++++++-- .../user-passkey-management.composable.tsx | 61 +++++-- .../domain-table.composable.tsx | 75 ++++++-- .../organization-details-edit.composable.tsx | 60 +++++-- .../organization-member-detail.composable.tsx | 69 ++++++-- ...anization-member-management.composable.tsx | 70 ++++++-- .../sso-provider-create.composable.tsx | 57 ++++-- .../sso-provider-edit.composable.tsx | 65 ++++--- .../sso-provider-table.composable.tsx | 75 ++++++-- .../src/hooks/my-account/use-user-mfa.ts | 86 ++++++--- .../src/hooks/my-account/use-user-passkey.ts | 49 +++-- .../hooks/my-organization/use-domain-table.ts | 137 +++++++++----- .../my-organization/use-member-detail.ts | 85 ++++++--- .../use-organization-details-edit.ts | 41 +++-- .../use-organization-member-management.ts | 167 +++++++++++------- .../use-sso-provider-create.ts | 65 ++++--- .../my-organization/use-sso-provider-edit.ts | 40 +++-- .../my-organization/use-sso-provider-table.ts | 101 +++++++---- 18 files changed, 994 insertions(+), 388 deletions(-) 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 index e751f8ab5..bbac12241 100644 --- 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 @@ -64,6 +64,20 @@ const DEFAULT_STYLING: NonNullable = { 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; @@ -79,20 +93,22 @@ export interface UserMFAManagementRootProps extends UserMFAManagementProps { * @param props - {@link UserMFAManagementRootProps} * @returns The provider-wrapped subtree. */ -function Root({ children, ...props }: UserMFAManagementRootProps) { +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 { - customMessages = {}, - styling = DEFAULT_STYLING, - showActiveOnly = false, - disableDelete = false, - readOnly = false, - factorConfig = {}, - enrollAction, - deleteAction, - } = props; - const model = useUserMFA({ showActiveOnly, readOnly, @@ -109,9 +125,41 @@ function Root({ children, ...props }: UserMFAManagementRootProps) { [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: { ...props, styling, customMessages } }), - [model, props, styling, customMessages], + () => ({ + 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 ( @@ -200,9 +248,10 @@ Content.displayName = 'UserMFAManagement.Content'; * @returns The default layout subtree. */ function DefaultLayout() { + const { props } = useUserMFAManagementContext(); return ( <> - + {!props.hideHeader && } ); 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 index 4dbb98c50..b45dd2309 100644 --- 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 @@ -62,6 +62,13 @@ const DEFAULT_STYLING: NonNullable = { 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; @@ -73,18 +80,20 @@ export interface UserPasskeyManagementRootProps extends UserPasskeyManagementPro * @param props - {@link UserPasskeyManagementRootProps} * @returns The provider-wrapped subtree. */ -function Root({ children, ...props }: UserPasskeyManagementRootProps) { +function Root({ + children, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, + hideHeader = false, + schema, + readOnly, + isLoading, + addAction, + revokeAction, + onFetch, +}: UserPasskeyManagementRootProps) { useTelemetry('user-passkey-management'); - const { - customMessages = {}, - styling = DEFAULT_STYLING, - hideHeader = false, - addAction, - revokeAction, - onFetch, - } = props; - const model = useUserPasskey({ customMessages, addAction, @@ -98,9 +107,37 @@ function Root({ children, ...props }: UserPasskeyManagementRootProps) { [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: { ...props, styling, customMessages, hideHeader } }), - [model, props, styling, customMessages, hideHeader], + () => ({ + model, + props: { + styling, + customMessages, + schema, + readOnly, + hideHeader, + isLoading, + addAction, + revokeAction, + onFetch, + }, + }), + [ + model, + styling, + customMessages, + schema, + readOnly, + hideHeader, + isLoading, + addAction, + revokeAction, + onFetch, + ], ); return ( 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 index 2981c8b78..4e4ebae5e 100644 --- a/packages/react/src/components/auth0/my-organization/domain-table.composable.tsx +++ b/packages/react/src/components/auth0/my-organization/domain-table.composable.tsx @@ -54,6 +54,13 @@ const DEFAULT_STYLING: NonNullable = { 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; @@ -65,20 +72,23 @@ export interface DomainTableRootProps extends DomainTableProps { * @param props - {@link DomainTableRootProps} * @returns The provider-wrapped subtree. */ -function Root({ children, ...props }: DomainTableRootProps) { +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 { - customMessages = {}, - styling = DEFAULT_STYLING, - readOnly = false, - createAction, - verifyAction, - deleteAction, - associateToProviderAction, - deleteFromProviderAction, - } = props; - const model = useDomainTable({ createAction, verifyAction, @@ -94,9 +104,43 @@ function Root({ children, ...props }: DomainTableRootProps) { [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: { ...props, styling, readOnly, customMessages } }), - [model, props, styling, readOnly, customMessages], + () => ({ + 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 ( @@ -251,9 +295,10 @@ Content.displayName = 'DomainTable.Content'; * @returns The default layout subtree. */ function DefaultLayout({ children }: { children?: React.ReactNode }) { + const { props } = useDomainTableContext(); return ( <> - + {!props.hideHeader && } diff --git a/packages/react/src/components/auth0/my-organization/organization-details-edit.composable.tsx b/packages/react/src/components/auth0/my-organization/organization-details-edit.composable.tsx index 30c849699..9dd366dfa 100644 --- a/packages/react/src/components/auth0/my-organization/organization-details-edit.composable.tsx +++ b/packages/react/src/components/auth0/my-organization/organization-details-edit.composable.tsx @@ -59,6 +59,13 @@ const DEFAULT_STYLING: NonNullable = { 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; @@ -70,17 +77,20 @@ export interface OrganizationDetailsEditRootProps extends OrganizationDetailsEdi * @param props - {@link OrganizationDetailsEditRootProps} * @returns The provider-wrapped subtree. */ -function Root({ children, ...props }: OrganizationDetailsEditRootProps) { +function Root({ + children, + saveAction, + cancelAction, + readOnly = false, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, + schema, + hideHeader, + isLoading, + backButton, +}: OrganizationDetailsEditRootProps) { useTelemetry('organization-details'); - const { - saveAction, - cancelAction, - readOnly = false, - customMessages = {}, - styling = DEFAULT_STYLING, - } = props; - const model = useOrganizationDetailsEdit({ saveAction, cancelAction, @@ -94,9 +104,37 @@ function Root({ children, ...props }: OrganizationDetailsEditRootProps) { [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: { ...props, styling, readOnly, customMessages } }), - [model, props, styling, readOnly, customMessages], + () => ({ + model, + props: { + styling, + customMessages, + schema, + readOnly, + hideHeader, + isLoading, + saveAction, + cancelAction, + backButton, + }, + }), + [ + model, + styling, + customMessages, + schema, + readOnly, + hideHeader, + isLoading, + saveAction, + cancelAction, + backButton, + ], ); return ( 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 index bef6284a0..21a5024be 100644 --- 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 @@ -63,6 +63,13 @@ const DEFAULT_STYLING: NonNullable = { 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; @@ -78,18 +85,20 @@ export interface OrganizationMemberDetailRootProps extends OrganizationMemberDet * @param props - {@link OrganizationMemberDetailRootProps} * @returns The provider-wrapped subtree. */ -function Root({ children, ...props }: OrganizationMemberDetailRootProps) { - const { - userId, - onBack, - customMessages = {}, - styling = DEFAULT_STYLING, - initialTab, - removeFromOrganizationAction, - assignRolesAction, - removeRolesAction, - } = props; - +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, @@ -106,9 +115,41 @@ function Root({ children, ...props }: OrganizationMemberDetailRootProps) { [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: { ...props, styling, customMessages } }), - [model, props, styling, customMessages], + () => ({ + 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 ( 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 index 78b587daf..fcf846d83 100644 --- 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 @@ -53,6 +53,13 @@ const DEFAULT_STYLING: NonNullable 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; @@ -64,19 +71,20 @@ export interface OrganizationMemberManagementRootProps extends OrganizationMembe * @param props - {@link OrganizationMemberManagementRootProps} * @returns The provider-wrapped subtree. */ -function Root({ children, ...props }: OrganizationMemberManagementRootProps) { - const { - customMessages = {}, - styling = DEFAULT_STYLING, - readOnly = false, - createInvitationAction, - revokeInvitationAction, - resendInvitationAction, - viewMemberDetailsAction, - assignRolesAction, - removeFromOrganizationAction, - } = props; - +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, @@ -94,9 +102,41 @@ function Root({ children, ...props }: OrganizationMemberManagementRootProps) { [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: { ...props, styling, readOnly, customMessages } }), - [model, props, styling, readOnly, customMessages], + () => ({ + 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 ( 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 index 554113af3..3745ebb6c 100644 --- 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 @@ -75,6 +75,13 @@ const DEFAULT_STYLING: NonNullable = { 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; @@ -90,17 +97,19 @@ export interface SsoProviderCreateRootProps extends SsoProviderCreateProps { * @param props - {@link SsoProviderCreateRootProps} * @returns The provider-wrapped subtree. */ -function Root({ children, ...props }: SsoProviderCreateRootProps) { +function Root({ + children, + createAction, + backButton, + schema, + readOnly = false, + onNext, + onPrevious, + customMessages = EMPTY_CUSTOM_MESSAGES, + styling = DEFAULT_STYLING, +}: SsoProviderCreateRootProps) { useTelemetry('sso-create-configuration'); - const { - createAction, - customMessages = {}, - styling = DEFAULT_STYLING, - onNext, - onPrevious, - } = props; - const model = useSsoProviderCreate({ createAction, customMessages, @@ -114,9 +123,35 @@ function Root({ children, ...props }: SsoProviderCreateRootProps) { [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: { ...props, styling, customMessages } }), - [model, props, styling, customMessages], + () => ({ + model, + props: { + createAction, + backButton, + schema, + readOnly, + onNext, + onPrevious, + customMessages, + styling, + }, + }), + [ + model, + createAction, + backButton, + schema, + readOnly, + onNext, + onPrevious, + customMessages, + styling, + ], ); return ( 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 index 85df53aaf..7b23c6988 100644 --- 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 @@ -65,6 +65,13 @@ const DEFAULT_STYLING: NonNullable = { 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; @@ -76,25 +83,26 @@ export interface SsoProviderEditRootProps extends SsoProviderEditProps { * @param props - {@link SsoProviderEditRootProps} * @returns The provider-wrapped subtree. */ -function Root({ children, ...props }: SsoProviderEditRootProps) { +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 { - providerId, - sso, - provisioning, - domains, - hideHeader = false, - hideProvisioningTab = false, - hideDeleteProvider = false, - hideRemoveFromOrganization = false, - hideAttributeMappings = false, - customMessages = {}, - styling = DEFAULT_STYLING, - readOnly = false, - enableProviderAction, - } = props; - const model = useSsoProviderEdit(providerId, { sso, provisioning, @@ -112,11 +120,20 @@ function Root({ children, ...props }: SsoProviderEditRootProps) { [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: { - ...props, + providerId, + sso, + provisioning, + domains, + backButton, + schema, styling, readOnly, customMessages, @@ -125,11 +142,17 @@ function Root({ children, ...props }: SsoProviderEditRootProps) { hideDeleteProvider, hideRemoveFromOrganization, hideAttributeMappings, + enableProviderAction, }, }), [ model, - props, + providerId, + sso, + provisioning, + domains, + backButton, + schema, styling, readOnly, customMessages, @@ -138,6 +161,7 @@ function Root({ children, ...props }: SsoProviderEditRootProps) { hideDeleteProvider, hideRemoveFromOrganization, hideAttributeMappings, + enableProviderAction, ], ); @@ -241,9 +265,10 @@ Content.displayName = 'SsoProviderEdit.Content'; * @returns The default layout subtree. */ function DefaultLayout() { + const { props } = useSsoProviderEditContext(); return ( <> - + {!props.hideHeader && } ); 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 index 46ca37739..43d289869 100644 --- 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 @@ -54,6 +54,13 @@ const DEFAULT_STYLING: NonNullable = { 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; @@ -65,20 +72,23 @@ export interface SsoProviderTableRootProps extends SsoProviderTableProps { * @param props - {@link SsoProviderTableRootProps} * @returns The provider-wrapped subtree. */ -function Root({ children, ...props }: SsoProviderTableRootProps) { +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 { - customMessages = {}, - styling = DEFAULT_STYLING, - readOnly = false, - createAction, - editAction, - deleteAction, - deleteFromOrganizationAction, - enableProviderAction, - } = props; - const model = useSsoProviderTable({ readOnly, customMessages, @@ -95,9 +105,43 @@ function Root({ children, ...props }: SsoProviderTableRootProps) { [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: { ...props, styling, readOnly, customMessages } }), - [model, props, styling, readOnly, customMessages], + () => ({ + 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 ( @@ -257,9 +301,10 @@ Content.displayName = 'SsoProviderTable.Content'; * @returns The default layout subtree. */ function DefaultLayout({ children }: { children?: React.ReactNode }) { + const { props } = useSsoProviderTableContext(); return ( <> - + {!props.hideHeader && } 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..2d1262432 100644 --- a/packages/react/src/hooks/my-account/use-user-mfa.ts +++ b/packages/react/src/hooks/my-account/use-user-mfa.ts @@ -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..f1b211fff 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'; @@ -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 c29cc842f..4978136d1 100644 --- a/packages/react/src/hooks/my-organization/use-domain-table.ts +++ b/packages/react/src/hooks/my-organization/use-domain-table.ts @@ -5,7 +5,7 @@ */ import { type Domain, type IdpKnownResponse } from '@auth0/universal-components-core'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; import { useDomainTableService } from '@/hooks/my-organization/shared/services/use-domain-table-service'; @@ -274,55 +274,96 @@ export function useDomainTable({ [changePageSize], ); - return { - // Data - domains, - providers, + return useMemo( + () => ({ + // 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, + }), + [ + 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 494d69dbb..efd8b2b95 100644 --- a/packages/react/src/hooks/my-organization/use-member-detail.ts +++ b/packages/react/src/hooks/my-organization/use-member-detail.ts @@ -158,32 +158,61 @@ export function useOrganizationMemberDetail( ? resolveErrorMessage(memberQuery.error, t('member.detail.error.fetch_failed')) : null; - return { - activeTab, - member, - organizationDisplayName, - memberRoles, - searchedRoles, - onRoleSearch: setRoleSearchTerm, - selectedRoles, - memberError: memberErrorMessage, - isFetchingMember: memberQuery.isLoading || memberQuery.isFetching, - isFetchingMemberRoles: memberRolesQuery.isLoading, - 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, + member, + organizationDisplayName, + memberRoles, + searchedRoles, + onRoleSearch: setRoleSearchTerm, + selectedRoles, + memberError: memberErrorMessage, + isFetchingMember: memberQuery.isLoading || memberQuery.isFetching, + isFetchingMemberRoles: memberRolesQuery.isLoading, + 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, + member, + organizationDisplayName, + memberRoles, + searchedRoles, + setRoleSearchTerm, + selectedRoles, + memberErrorMessage, + memberQuery.isLoading, + memberQuery.isFetching, + memberRolesQuery.isLoading, + 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 8d2c5c4d8..9f6cb0238 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 @@ -65,17 +65,32 @@ export function useOrganizationDetailsEdit({ ], ); - return { - 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( + () => ({ + organization: service.organization, + isFetchLoading: service.isFetchLoading, + isSaveLoading: service.isSaveLoading, + isInitializing: service.isInitializing, + isLoadingConfig, + formActions, + fetchOrgDetails: service.fetchOrgDetails, + updateOrgDetails: service.updateOrgDetails, + showThirdPartyAccess, + isThirdPartyAccessReadOnly, + thirdPartyAccessDefaultValue, + }), + [ + 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 f270ddabe..ef785a0c7 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 @@ -283,65 +283,114 @@ export function useOrganizationMemberManagement( [activeTab, invitationChangeFilters, memberChangeFilters], ); - return { - activeTab, - availableRoles, - searchedRoles, - onRoleSearch: setRoleSearchTerm, - availableProviders, + return React.useMemo( + () => ({ + activeTab, + availableRoles, + searchedRoles, + onRoleSearch: setRoleSearchTerm, + availableProviders, - invitations: currentInvitations, - members: currentMembers, - organizationDisplayName: organizationDisplayName, - isInitialLoading: membersQuery.isLoading, - isFetchingInvitations: invitationsQuery.isFetching, - isFetchingMembers: membersQuery.isFetching, - isMembersStale: membersQuery.isStale, - isInvitationsStale: invitationsQuery.isStale, - membersUpdatedAt: membersQuery.dataUpdatedAt, - invitationsUpdatedAt: invitationsQuery.dataUpdatedAt, - refetchMembers: membersQuery.refetch, - refetchInvitations: invitationsQuery.refetch, - isFetchingAvailableRoles: rolesQuery.isLoading || rolesQuery.isFetching, - isRemovingFromOrganization: isMutationLoading(removeFromOrganizationMutation), - isAssigningRoles: isMutationLoading(assignRolesMutation), - isLoadingMemberRoles: memberRolesQuery.isLoading, - memberRoles: memberRolesQuery.data, - isCreatingInvitation: isMutationLoading(createInvitationMutation), - isRevokingInvitation: isMutationLoading(revokeInvitationMutation), - isResendingInvitation: isMutationLoading(resendInvitationMutation), - invitationPagination: { - pageSize: invitationPageSize, - currentPage: invitationCurrentPage, - hasNextPage: !!invitationNextToken, - hasPreviousPage: invitationHasPreviousPage, - }, - memberPagination: { - pageSize: memberPageSize, - currentPage: memberCurrentPage, - hasNextPage: !!memberNextToken, - hasPreviousPage: memberHasPreviousPage, - }, - invitationFilters, - invitationSortConfig, - memberFilters, - memberSortConfig, - modalState, + invitations: currentInvitations, + members: currentMembers, + organizationDisplayName: organizationDisplayName, + isInitialLoading: membersQuery.isLoading, + isFetchingInvitations: invitationsQuery.isFetching, + isFetchingMembers: membersQuery.isFetching, + isMembersStale: membersQuery.isStale, + isInvitationsStale: invitationsQuery.isStale, + membersUpdatedAt: membersQuery.dataUpdatedAt, + invitationsUpdatedAt: invitationsQuery.dataUpdatedAt, + refetchMembers: membersQuery.refetch, + refetchInvitations: invitationsQuery.refetch, + isFetchingAvailableRoles: rolesQuery.isLoading || rolesQuery.isFetching, + isRemovingFromOrganization: isMutationLoading(removeFromOrganizationMutation), + isAssigningRoles: isMutationLoading(assignRolesMutation), + isLoadingMemberRoles: memberRolesQuery.isLoading, + memberRoles: memberRolesQuery.data, + isCreatingInvitation: isMutationLoading(createInvitationMutation), + isRevokingInvitation: isMutationLoading(revokeInvitationMutation), + isResendingInvitation: isMutationLoading(resendInvitationMutation), + invitationPagination: { + pageSize: invitationPageSize, + currentPage: invitationCurrentPage, + hasNextPage: !!invitationNextToken, + hasPreviousPage: invitationHasPreviousPage, + }, + memberPagination: { + pageSize: memberPageSize, + currentPage: memberCurrentPage, + hasNextPage: !!memberNextToken, + hasPreviousPage: memberHasPreviousPage, + }, + invitationFilters, + invitationSortConfig, + memberFilters, + memberSortConfig, + modalState, - setActiveTab, - openModal, - closeModal, - handleCreateSubmit, - handleRevokeConfirm, - handleRevokeResendConfirm, - handleCopyUrl, - handleNextPage, - handlePreviousPage, - handlePageSizeChange, - handleSortChange, - handleRoleFilterChange, - handleViewMemberDetails, - handleAssignRolesSubmit, - handleRemoveFromOrganizationConfirm, - }; + setActiveTab, + openModal, + closeModal, + handleCreateSubmit, + handleRevokeConfirm, + handleRevokeResendConfirm, + handleCopyUrl, + handleNextPage, + handlePreviousPage, + handlePageSizeChange, + handleSortChange, + handleRoleFilterChange, + handleViewMemberDetails, + handleAssignRolesSubmit, + handleRemoveFromOrganizationConfirm, + }), + [ + activeTab, + availableRoles, + searchedRoles, + setRoleSearchTerm, + availableProviders, + currentInvitations, + currentMembers, + organizationDisplayName, + membersQuery, + invitationsQuery, + rolesQuery, + removeFromOrganizationMutation, + assignRolesMutation, + memberRolesQuery, + createInvitationMutation, + revokeInvitationMutation, + resendInvitationMutation, + invitationPageSize, + invitationCurrentPage, + invitationNextToken, + invitationHasPreviousPage, + memberPageSize, + memberCurrentPage, + memberNextToken, + memberHasPreviousPage, + invitationFilters, + invitationSortConfig, + memberFilters, + memberSortConfig, + modalState, + setActiveTab, + openModal, + closeModal, + handleCreateSubmit, + handleRevokeConfirm, + handleRevokeResendConfirm, + 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 22cd1f9ed..be3fa0c2c 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 @@ -4,7 +4,7 @@ * @module use-sso-provider-create */ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useConfig } from '@/hooks/my-organization/shared/services/use-config-service'; import { useIdpConfig } from '@/hooks/my-organization/shared/services/use-idp-config-service'; @@ -92,24 +92,47 @@ export function useSsoProviderCreate({ }); }, [strategy, details, createProvider]); - return { - 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( + () => ({ + formData, + setFormData, + createStepActions, + handleCreate, + detailsRef, + configureRef, + isCreating, + isLoadingConfig, + filteredStrategies, + isLoadingIdpConfig, + idpConfig, + showThirdPartyAccess, + isThirdPartyAccessReadOnly, + thirdPartyAccessDefaultValue, + showCrossAppAccess: showCrossAppAccess(strategy), + isCrossAppAccessReadOnly: isCrossAppAccessReadOnly(strategy), + getCrossAppAccessDefaultValue: () => getCrossAppAccessDefaultValue(strategy), + isOrganizationBlocked, + }), + [ + 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 e5129e97e..87cf4c622 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 @@ -5,7 +5,7 @@ */ import type { IdpId } from '@auth0/universal-components-core'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useConfig } from '@/hooks/my-organization/shared/services/use-config-service'; import { useIdpConfig } from '@/hooks/my-organization/shared/services/use-idp-config-service'; @@ -63,16 +63,30 @@ export function useSsoProviderEdit( [service.provider?.strategy, service.enableProvider], ); - return { - ...service, - shouldAllowDeletion, - isLoadingConfig, - idpConfig, - isLoadingIdpConfig, - showProvisioningTab, - handleToggleProvider, - showThirdPartyAccess, - showCrossAppAccess: showCrossAppAccess(service.provider?.strategy), - isCrossAppAccessReadOnly: isCrossAppAccessReadOnly(service.provider?.strategy), - }; + return useMemo( + () => ({ + ...service, + shouldAllowDeletion, + isLoadingConfig, + idpConfig, + isLoadingIdpConfig, + showProvisioningTab, + handleToggleProvider, + showThirdPartyAccess, + showCrossAppAccess: showCrossAppAccess(service.provider?.strategy), + isCrossAppAccessReadOnly: isCrossAppAccessReadOnly(service.provider?.strategy), + }), + [ + service, + shouldAllowDeletion, + isLoadingConfig, + idpConfig, + isLoadingIdpConfig, + showProvisioningTab, + handleToggleProvider, + showThirdPartyAccess, + 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 dddd61ea3..6215aaa7c 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 @@ -6,7 +6,7 @@ import type { IdpKnownResponse } from '@auth0/universal-components-core'; import { ssoProviderQueryKeys } from '@auth0/universal-components-core'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { showToast } from '@/components/auth0/shared/toast'; import { useConfig } from '@/hooks/my-organization/shared/services/use-config-service'; @@ -206,41 +206,74 @@ export function useSsoProviderTable({ [onRemoveConfirm, organization?.display_name, t, handleError], ); - return { - providers, - organization, + return useMemo( + () => ({ + 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, - - handleCreate, - handleEdit, - handleDelete, - handleDeleteFromOrganization, - handleToggleEnabled, - handleDeleteConfirm, - handleRemoveConfirm, - - setShowDeleteModal, - setShowRemoveModal, - setSelectedIdp, - }; + refetchProviders, + fetchProviders, + fetchOrganizationDetails: handleFetchOrganizationDetails, + + handleCreate, + handleEdit, + handleDelete, + handleDeleteFromOrganization, + handleToggleEnabled, + handleDeleteConfirm, + handleRemoveConfirm, + + setShowDeleteModal, + setShowRemoveModal, + setSelectedIdp, + }), + [ + 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, + ], + ); } From 6e77504d2e3394fd3d27c0d2b20c7640108c7107 Mon Sep 17 00:00:00 2001 From: harish-sundar_akto Date: Mon, 28 Sep 2026 14:13:04 +0530 Subject: [PATCH 03/11] fix(react): harden render-prop and restore header spacing --- .../user-passkey-management.composable.tsx | 5 +- .../domain-table.composable.tsx | 5 +- .../organization-details-edit.composable.tsx | 5 +- ...anization-member-management.composable.tsx | 5 +- .../sso-provider-table.composable.tsx | 5 +- .../__tests__/render-prop.test.tsx | 110 ++++++++++++++++++ .../src/lib/composability/render-prop.tsx | 34 +++++- 7 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 packages/react/src/lib/composability/__tests__/render-prop.test.tsx 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 index b45dd2309..4aa913e21 100644 --- 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 @@ -182,7 +182,10 @@ function AddAction({ render }: UserPasskeyManagementAddActionProps) { if (render) { // The host element owns its own label/content; we only wire behavior. - return mergeRenderProp(render, { type: 'button', disabled, onClick }); + // `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 ( 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 index 4e4ebae5e..859b3c88f 100644 --- a/packages/react/src/components/auth0/my-organization/domain-table.composable.tsx +++ b/packages/react/src/components/auth0/my-organization/domain-table.composable.tsx @@ -178,7 +178,10 @@ function CreateAction({ render }: DomainTableCreateActionProps) { if (render) { // The host element owns its own label/content; we only wire behavior. - return mergeRenderProp(render, { type: 'button', disabled, onClick }); + // `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 ( diff --git a/packages/react/src/components/auth0/my-organization/organization-details-edit.composable.tsx b/packages/react/src/components/auth0/my-organization/organization-details-edit.composable.tsx index 9dd366dfa..7bfc872d9 100644 --- a/packages/react/src/components/auth0/my-organization/organization-details-edit.composable.tsx +++ b/packages/react/src/components/auth0/my-organization/organization-details-edit.composable.tsx @@ -40,6 +40,7 @@ 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, @@ -171,7 +172,9 @@ function OrganizationDetailsEditHeader({ className }: OrganizationDetailsEditHea 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. +
{ + 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/render-prop.tsx b/packages/react/src/lib/composability/render-prop.tsx index 16660df30..b97b2c02b 100644 --- a/packages/react/src/lib/composability/render-prop.tsx +++ b/packages/react/src/lib/composability/render-prop.tsx @@ -8,11 +8,18 @@ * reliably forward refs or accept arbitrary DOM props. * * Merge rules: - * - Behavioral props (`disabled`, `type`, `aria-*`, `ref`) — component wins. + * - `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`. + * 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 */ @@ -34,26 +41,41 @@ export interface RenderPropOwnProps { * * @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. + * @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 { +): 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 && !ownDisabled) { + if (!event.defaultPrevented && !mergedDisabled) { ownOnClick?.(event); } }; return React.cloneElement(render, { ...restOwnProps, - disabled: ownDisabled ?? hostProps.disabled, + disabled: mergedDisabled, onClick: mergedOnClick, } as Partial & React.Attributes); } From 813f6d3c491afcc78cb98dba6c31987a0b52a0fc Mon Sep 17 00:00:00 2001 From: harish-sundar_akto Date: Mon, 28 Sep 2026 14:20:21 +0530 Subject: [PATCH 04/11] fix(react): accessibility fixes for shared header and actions --- .../user-mfa-management.composable.test.tsx | 4 +- .../user-passkey-management.composable.tsx | 7 +- .../sso-provider-edit.composable.tsx | 11 +++- .../my-organization/sso-provider-edit.tsx | 11 +++- .../auth0/shared/__tests__/header.test.tsx | 65 +++++++++++++++++++ .../src/components/auth0/shared/header.tsx | 64 ++++++++++++------ 6 files changed, 133 insertions(+), 29 deletions(-) create mode 100644 packages/react/src/components/auth0/shared/__tests__/header.test.tsx 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 index fdc34ec34..565a3a662 100644 --- 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 @@ -104,9 +104,9 @@ describe('UserMFAManagement — composability', () => { 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 banner/title; Content + // second header. The Header part renders the only heading/title; Content // suppresses its own header (hideHeader). - expect(screen.getAllByRole('banner')).toHaveLength(1); + 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. 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 index 4aa913e21..1517d5c76 100644 --- 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 @@ -184,7 +184,12 @@ function AddAction({ render }: UserPasskeyManagementAddActionProps) { // 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 }); + const merged = mergeRenderProp(render, { + type: 'button', + disabled, + 'aria-busy': disabled, + onClick, + }); if (merged) return merged; } 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 index 7b23c6988..9b2ebbaf2 100644 --- 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 @@ -192,6 +192,12 @@ function SsoProviderEditHeader({ className }: SsoProviderEditHeaderProps) { const { model, props } = useSsoProviderEditContext(); const { t } = useTranslator('idp_management.edit_sso_provider', props.customMessages); + // 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 (
@@ -183,13 +189,12 @@ function SsoProviderEditView(props: SsoProviderEditViewProps) { actions={[ { type: 'switch', + 'aria-label': toggleLabel, checked: provider?.is_enabled ?? false, onCheckedChange: handleToggleProvider, disabled: readOnly || isUpdating || isEnabling || enableProviderAction?.disabled, tooltip: { - content: provider?.is_enabled - ? t('header.disable_provider_tooltip_text') - : t('header.enable_provider_tooltip_text'), + content: toggleLabel, }, }, ]} diff --git a/packages/react/src/components/auth0/shared/__tests__/header.test.tsx b/packages/react/src/components/auth0/shared/__tests__/header.test.tsx new file mode 100644 index 000000000..83edc211e --- /dev/null +++ b/packages/react/src/components/auth0/shared/__tests__/header.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Header, type ActionProps } from '@/components/auth0/shared/header'; + +describe('Header — accessibility', () => { + 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 7a2b33176..d73ad5cb2 100644 --- a/packages/react/src/components/auth0/shared/header.tsx +++ b/packages/react/src/components/auth0/shared/header.tsx @@ -59,6 +59,12 @@ export interface HeaderProps { 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 }) => ( @@ -72,7 +78,7 @@ const WithTooltip: React.FC = ({ trigger, tooltip }) => ( ); -const ButtonAction: React.FC = ({ +const ButtonAction: React.FC = ({ icon: Icon, className, label, @@ -80,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 && }
@@ -116,24 +131,36 @@ export const Header = React.forwardRef< HeaderProps & React.HTMLAttributes >( ( - { title, description, backButton, actions, actionSlot, isLoading, className, ...props }, + { + 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 (isLoading) { - return ; - } 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 ( @@ -144,13 +171,10 @@ export const Header = React.forwardRef< }; 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 && (