From 9546b3098cab5fc18663ee1d1af0934b7d23dcd7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 10 Jul 2026 14:19:40 -0400 Subject: [PATCH 1/7] feat(ui): add Mosaic OrganizationProfile security panel (overview + wizard engine + domains step) Migrates the Enterprise SSO Security panel from the legacy styled-system components into the Mosaic machine -> controller -> view architecture. - Overview: status badge, activate/deactivate/remove (type-to-confirm), domain chips, tab gating shared with the shell. - Wizard engine: 4-step machine (verify-domain -> configure -> test -> activate) with furthest-reachable seat, recheck re-seat, and deferred-advance; proven 1:1 against the legacy reducer by equivalence test. - Verify-domain step: three concurrent mutation machines (add / prepare / remove) matching the domains-section pattern, plus mount telemetry. configure / test / activate render placeholders pending follow-up commits. --- .changeset/mosaic-security-panel.md | 2 + ...le-security-panel-overview.machine.test.ts | 111 +++++ ...profile-security-panel.controller.test.tsx | 296 +++++++++++ ...ation-profile-security-panel.view.test.tsx | 323 ++++++++++++ ...ecurity-wizard-domains-add.machine.test.ts | 89 ++++ ...ity-wizard-domains-prepare.machine.test.ts | 44 ++ ...rity-wizard-domains-remove.machine.test.ts | 62 +++ ...file-security-wizard-domains.view.test.tsx | 208 ++++++++ ...rofile-security-wizard.equivalence.test.ts | 148 ++++++ ...on-profile-security-wizard.machine.test.ts | 189 +++++++ .../__tests__/organization-profile.test.tsx | 5 + ...profile-security-panel-overview.machine.ts | 121 +++++ ...tion-profile-security-panel.controller.tsx | 357 ++++++++++++++ .../organization-profile-security-panel.tsx | 31 ++ ...ganization-profile-security-panel.view.tsx | 461 ++++++++++++++++++ ...ile-security-wizard-domains-add.machine.ts | 87 ++++ ...security-wizard-domains-prepare.machine.ts | 63 +++ ...-security-wizard-domains-remove.machine.ts | 81 +++ ...n-profile-security-wizard-domains.view.tsx | 438 +++++++++++++++++ ...ization-profile-security-wizard.machine.ts | 199 ++++++++ .../organization-profile-view.tsx | 19 +- .../organization/organization-profile.tsx | 7 + 22 files changed, 3338 insertions(+), 3 deletions(-) create mode 100644 .changeset/mosaic-security-panel.md create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel-overview.machine.test.ts create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel.controller.test.tsx create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel.view.test.tsx create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-add.machine.test.ts create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-prepare.machine.test.ts create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-remove.machine.test.ts create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains.view.test.tsx create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard.equivalence.test.ts create mode 100644 packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard.machine.test.ts create mode 100644 packages/ui/src/mosaic/organization/organization-profile-security-panel-overview.machine.ts create mode 100644 packages/ui/src/mosaic/organization/organization-profile-security-panel.controller.tsx create mode 100644 packages/ui/src/mosaic/organization/organization-profile-security-panel.tsx create mode 100644 packages/ui/src/mosaic/organization/organization-profile-security-panel.view.tsx create mode 100644 packages/ui/src/mosaic/organization/organization-profile-security-wizard-domains-add.machine.ts create mode 100644 packages/ui/src/mosaic/organization/organization-profile-security-wizard-domains-prepare.machine.ts create mode 100644 packages/ui/src/mosaic/organization/organization-profile-security-wizard-domains-remove.machine.ts create mode 100644 packages/ui/src/mosaic/organization/organization-profile-security-wizard-domains.view.tsx create mode 100644 packages/ui/src/mosaic/organization/organization-profile-security-wizard.machine.ts diff --git a/.changeset/mosaic-security-panel.md b/.changeset/mosaic-security-panel.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-security-panel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel-overview.machine.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel-overview.machine.test.ts new file mode 100644 index 00000000000..17d36213aea --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel-overview.machine.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import type { OrganizationProfileSecurityPanelOverviewContext } from '../organization-profile-security-panel-overview.machine'; +import { organizationProfileSecurityPanelOverviewMachine } from '../organization-profile-security-panel-overview.machine'; + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +const startActor = (context: Partial) => { + const actor = createActor(organizationProfileSecurityPanelOverviewMachine, { context }); + actor.start(); + return actor; +}; + +describe('organizationProfileSecurityPanelOverviewMachine', () => { + it('starts idle', () => { + const actor = startActor({}); + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('activates via the injected mutation and returns to idle', async () => { + const activateConnection = vi.fn(() => Promise.resolve()); + const actor = startActor({ activateConnection }); + + actor.send({ type: 'ACTIVATE' }); + expect(actor.getSnapshot().value).toBe('activating'); + expect(activateConnection).toHaveBeenCalledTimes(1); + + await tick(); + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.error).toBeNull(); + }); + + it('deactivates via the injected mutation and returns to idle', async () => { + const deactivateConnection = vi.fn(() => Promise.resolve()); + const actor = startActor({ deactivateConnection }); + + actor.send({ type: 'DEACTIVATE' }); + expect(actor.getSnapshot().value).toBe('deactivating'); + expect(deactivateConnection).toHaveBeenCalledTimes(1); + + await tick(); + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('surfaces the global error message and returns to idle when activate fails', async () => { + const activateConnection = vi.fn(() => Promise.reject(new Error('boom'))); + const actor = startActor({ activateConnection }); + + actor.send({ type: 'ACTIVATE' }); + await tick(); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.error).toBe('boom'); + }); + + it('guards remove until the typed org name matches', () => { + const removeConnection = vi.fn(() => Promise.resolve()); + const actor = startActor({ organizationName: 'Acme Inc', removeConnection }); + + actor.send({ type: 'OPEN_REMOVE' }); + actor.send({ type: 'CONFIRM_REMOVE' }); + + expect(actor.getSnapshot().value).toBe('confirmingRemove'); + expect(removeConnection).not.toHaveBeenCalled(); + }); + + it('removes the connection after a valid confirmation, then returns to idle cleared', async () => { + const removeConnection = vi.fn(() => Promise.resolve()); + const actor = startActor({ organizationName: 'Acme Inc', removeConnection }); + + actor.send({ type: 'OPEN_REMOVE' }); + actor.send({ type: 'TYPE_CONFIRMATION', value: 'Acme Inc' }); + actor.send({ type: 'CONFIRM_REMOVE' }); + + expect(actor.getSnapshot().value).toBe('removing'); + expect(removeConnection).toHaveBeenCalledTimes(1); + + await tick(); + // Not a final state — the overview stays live so the user can reconfigure. + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().status).toBe('active'); + expect(actor.getSnapshot().context.confirmationValue).toBe(''); + expect(actor.getSnapshot().context.error).toBeNull(); + }); + + it('returns to confirmingRemove with an error when removal fails', async () => { + const removeConnection = vi.fn(() => Promise.reject(new Error('cannot delete'))); + const actor = startActor({ organizationName: 'Acme Inc', removeConnection }); + + actor.send({ type: 'OPEN_REMOVE' }); + actor.send({ type: 'TYPE_CONFIRMATION', value: 'Acme Inc' }); + actor.send({ type: 'CONFIRM_REMOVE' }); + + await tick(); + expect(actor.getSnapshot().value).toBe('confirmingRemove'); + expect(actor.getSnapshot().context.error).toBe('cannot delete'); + }); + + it('clears the typed confirmation + error when the remove dialog is cancelled (reset-on-close)', () => { + const actor = startActor({ organizationName: 'Acme Inc' }); + + actor.send({ type: 'OPEN_REMOVE' }); + actor.send({ type: 'TYPE_CONFIRMATION', value: 'partial' }); + actor.send({ type: 'CANCEL_REMOVE' }); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.confirmationValue).toBe(''); + expect(actor.getSnapshot().context.error).toBeNull(); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel.controller.test.tsx b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel.controller.test.tsx new file mode 100644 index 00000000000..b92f31a0865 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel.controller.test.tsx @@ -0,0 +1,296 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { OrganizationEnterpriseConnection } from '@/components/ConfigureSSO/domain/organizationEnterpriseConnection'; + +import { useOrganizationProfileSecurityPanelController } from '../organization-profile-security-panel.controller'; + +let isOrganizationLoaded: boolean; +let isSessionLoaded: boolean; +let hasEnvironment: boolean; +let selfServeSSOFeature: boolean; +let selfServeSSOEnabled: boolean; +let canManage: boolean; +let organizationName: string; + +let enterpriseConnection: { id: string; domains: string[] } | undefined; +let connectionEntity: OrganizationEnterpriseConnection; +let organizationDomains: Array<{ ownershipVerification?: { status: string } }> | undefined; + +let setConnectionActive: ReturnType; +let deleteConnection: ReturnType; + +const buildEntity = (overrides: Partial = {}): OrganizationEnterpriseConnection => ({ + provider: undefined, + hasConnection: false, + isActive: false, + hasMinimumConfiguration: false, + hasSuccessfulTestRun: false, + status: 'unconfigured', + ...overrides, +}); + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useOrganization: () => ({ + isLoaded: isOrganizationLoaded, + organization: { id: 'org_1', name: organizationName, selfServeSSOEnabled }, + }), + useSession: () => ({ + isLoaded: isSessionLoaded, + session: isSessionLoaded + ? { + id: 'sess_1', + checkAuthorization: ({ permission }: { permission: string }) => + permission === 'org:sys_entconns:manage' ? canManage : false, + } + : undefined, + }), + useClerk: () => ({ telemetry: { record: vi.fn() } }), + }; +}); + +vi.mock('../../hooks/useMosaicEnvironment', () => ({ + useMosaicEnvironment: () => + hasEnvironment ? { userSettings: { enterpriseSSO: { self_serve_sso: selfServeSSOFeature } } } : undefined, +})); + +vi.mock('@/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection', () => ({ + useOrganizationEnterpriseConnection: () => ({ + isLoading: false, + user: { primaryEmailAddress: { emailAddress: 'admin@acme.com' } }, + organization: { id: 'org_1' }, + enterpriseConnection, + organizationEnterpriseConnection: connectionEntity, + organizationDomains, + enterpriseConnectionMutations: { setConnectionActive, deleteConnection, updateConnection: vi.fn() }, + organizationDomainMutations: { + createDomain: vi.fn().mockResolvedValue(undefined), + prepareOwnershipVerification: vi.fn().mockResolvedValue(undefined), + revalidate: vi.fn().mockResolvedValue(undefined), + }, + }), +})); + +beforeEach(() => { + isOrganizationLoaded = true; + isSessionLoaded = true; + hasEnvironment = true; + selfServeSSOFeature = true; + selfServeSSOEnabled = true; + canManage = true; + organizationName = 'Acme Inc'; + enterpriseConnection = { id: 'ent_1', domains: ['acme.com'] }; + connectionEntity = buildEntity({ hasConnection: true, isActive: true, status: 'active' }); + organizationDomains = [{ ownershipVerification: { status: 'verified' } }]; + setConnectionActive = vi.fn().mockResolvedValue(undefined); + deleteConnection = vi.fn().mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function Harness() { + const controller = useOrganizationProfileSecurityPanelController(); + if (controller.status !== 'ready') { + return {controller.status}; + } + return ( +
+ ready + {controller.mode} + {controller.organizationName} + {controller.connection.status} + {controller.overview.snapshot.value} + {controller.overview.snapshot.context.error ?? ''} + {String(controller.overview.canConfirmRemove)} + {controller.wizard.snapshot.value} + + + + + + + + + +
+ ); +} + +describe('useOrganizationProfileSecurityPanelController — gating', () => { + it('is loading until the organization is loaded', () => { + isOrganizationLoaded = false; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('loading'); + }); + + it('is loading until the session is loaded', () => { + isSessionLoaded = false; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('loading'); + }); + + it('is loading until the environment resolves', () => { + hasEnvironment = false; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('loading'); + }); + + it('is hidden when the enterprise-SSO feature flag is off', () => { + selfServeSSOFeature = false; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('hidden'); + }); + + it('is hidden when the organization has self-serve SSO disabled', () => { + selfServeSSOEnabled = false; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('hidden'); + }); + + it('is hidden when the user cannot manage enterprise connections', () => { + canManage = false; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('hidden'); + }); + + it('is ready with the overview when gating passes', () => { + render(); + expect(screen.getByTestId('state')).toHaveTextContent('ready'); + expect(screen.getByTestId('mode')).toHaveTextContent('overview'); + expect(screen.getByTestId('org-name')).toHaveTextContent('Acme Inc'); + expect(screen.getByTestId('status')).toHaveTextContent('active'); + }); +}); + +describe('useOrganizationProfileSecurityPanelController — overview mutation wiring', () => { + it('activates the connection via setConnectionActive(id, true)', async () => { + connectionEntity = buildEntity({ + hasConnection: true, + status: 'inactive', + hasMinimumConfiguration: true, + hasSuccessfulTestRun: true, + }); + render(); + + await act(async () => { + fireEvent.click(screen.getByText('Activate')); + }); + + expect(setConnectionActive).toHaveBeenCalledWith('ent_1', true); + expect(screen.getByTestId('overview-state')).toHaveTextContent('idle'); + }); + + it('deactivates the connection via setConnectionActive(id, false)', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByText('Deactivate')); + }); + + expect(setConnectionActive).toHaveBeenCalledWith('ent_1', false); + expect(screen.getByTestId('overview-state')).toHaveTextContent('idle'); + }); + + it('removes the connection only after a matching type-to-confirm', async () => { + render(); + + fireEvent.click(screen.getByText('Open remove')); + expect(screen.getByTestId('overview-state')).toHaveTextContent('confirmingRemove'); + + // A mismatch keeps the guard closed. + fireEvent.click(screen.getByText('Type mismatch')); + expect(screen.getByTestId('can-confirm-remove')).toHaveTextContent('false'); + fireEvent.click(screen.getByText('Confirm remove')); + expect(deleteConnection).not.toHaveBeenCalled(); + + // A match opens the guard and the confirm deletes the connection. + fireEvent.click(screen.getByText('Type match')); + expect(screen.getByTestId('can-confirm-remove')).toHaveTextContent('true'); + + await act(async () => { + fireEvent.click(screen.getByText('Confirm remove')); + }); + + expect(deleteConnection).toHaveBeenCalledWith('ent_1'); + expect(screen.getByTestId('overview-state')).toHaveTextContent('idle'); + }); + + it('surfaces the first global Clerk error message when a mutation fails', async () => { + setConnectionActive.mockRejectedValueOnce( + new ClerkAPIResponseError('request failed', { + data: [ + { + code: 'cannot_deactivate', + message: 'Cannot deactivate', + long_message: 'Cannot deactivate the only connection', + }, + ], + status: 422, + }), + ); + render(); + + await act(async () => { + fireEvent.click(screen.getByText('Deactivate')); + }); + + expect(screen.getByTestId('overview-error')).toHaveTextContent('Cannot deactivate the only connection'); + expect(screen.getByTestId('overview-state')).toHaveTextContent('idle'); + }); +}); + +describe('useOrganizationProfileSecurityPanelController — mode toggle', () => { + it('opens and exits the wizard', () => { + render(); + expect(screen.getByTestId('mode')).toHaveTextContent('overview'); + + fireEvent.click(screen.getByText('Open wizard')); + expect(screen.getByTestId('mode')).toHaveTextContent('wizard'); + + fireEvent.click(screen.getByText('Exit wizard')); + expect(screen.getByTestId('mode')).toHaveTextContent('overview'); + }); + + it('forces the wizard back to the first step when opened with forceInitialStep', () => { + // Domains verified → the wizard seats at `configure`; a forced open jumps back to step 0. + connectionEntity = buildEntity(); + render(); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('configure'); + + fireEvent.click(screen.getByText('Open wizard forced')); + expect(screen.getByTestId('mode')).toHaveTextContent('wizard'); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('verify-domain'); + }); +}); + +describe('useOrganizationProfileSecurityPanelController — wizard reachability + recheck', () => { + it('seats the wizard at the furthest reachable step from live connection data', () => { + // Domains verified + configured but no successful test run → configure and test reachable, + // activate closed → seat at `test`. + connectionEntity = buildEntity({ hasConnection: true, hasMinimumConfiguration: true, status: 'in_progress' }); + render(); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('test'); + }); + + it('re-seats the wizard when reachability data changes underneath it', () => { + connectionEntity = buildEntity({ hasConnection: true, hasMinimumConfiguration: true, status: 'in_progress' }); + const { rerender } = render(); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('test'); + + // The connection is removed elsewhere; domains stay verified so `configure` is still reachable. + enterpriseConnection = undefined; + connectionEntity = buildEntity(); + rerender(); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('configure'); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel.view.test.tsx new file mode 100644 index 00000000000..3c2aeb2b158 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel.view.test.tsx @@ -0,0 +1,323 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { OrganizationEnterpriseConnection } from '@/components/ConfigureSSO/domain/organizationEnterpriseConnection'; + +import type { Snapshot } from '../../machine/types'; +import { MosaicProvider } from '../../MosaicProvider'; +import type { OrganizationProfileSecurityPanelOverviewContext } from '../organization-profile-security-panel-overview.machine'; +import type { OrganizationProfileSecurityWizardContext } from '../organization-profile-security-wizard.machine'; +import type { OrganizationProfileSecurityPanelViewProps } from '../organization-profile-security-panel.view'; +import { OrganizationProfileSecurityPanelView } from '../organization-profile-security-panel.view'; + +const buildConnection = ( + overrides: Partial = {}, +): OrganizationEnterpriseConnection => ({ + provider: undefined, + hasConnection: false, + isActive: false, + hasMinimumConfiguration: false, + hasSuccessfulTestRun: false, + status: 'unconfigured', + ...overrides, +}); + +const overviewSnapshot = ( + value: string, + context: Partial = {}, +): Snapshot => ({ + value, + status: 'active', + context: { + organizationName: 'Acme Inc', + confirmationValue: '', + activateConnection: async () => {}, + deactivateConnection: async () => {}, + removeConnection: async () => {}, + error: null, + ...context, + }, +}); + +const wizardSnapshot = ( + value: string, + context: Partial = {}, +): Snapshot => ({ + value, + status: 'active', + context: { + direction: 0, + hasNavigated: false, + pendingNext: false, + allDomainsVerified: false, + hasConnection: false, + hasMinimumConfiguration: false, + isActive: false, + hasSuccessfulTestRun: false, + ...context, + }, +}); + +// A verify-domain step with empty machines — the panel-view tests only exercise the shell, +// so the step body just needs valid (idle) flows to render without crashing. +const buildDomainsStep = (): OrganizationProfileSecurityPanelViewProps['domainsStep'] => ({ + domains: [], + suggestedDomain: null, + hasConnection: false, + isConnectionActive: false, + add: { + snapshot: { + value: 'idle', + status: 'active', + context: { draftName: '', pendingName: '', error: null, createDomain: async () => {} }, + }, + send: vi.fn(), + }, + prepare: { + snapshot: { + value: 'idle', + status: 'active', + context: { pendingDomainId: '', error: null, prepareVerification: async () => {} }, + }, + send: vi.fn(), + }, + remove: { + snapshot: { + value: 'idle', + status: 'active', + context: { domainName: '', isConnectionActive: false, removeDomain: async () => {}, error: null }, + }, + send: vi.fn(), + }, + onStepMounted: vi.fn(), +}); + +function renderView(overrides: Partial = {}) { + const openWizard = vi.fn(); + const exitWizard = vi.fn(); + const overviewSend = vi.fn(); + const wizardSend = vi.fn(); + + const props: OrganizationProfileSecurityPanelViewProps = { + mode: 'overview', + isLoading: false, + organizationName: 'Acme Inc', + connection: buildConnection(), + enterpriseConnection: undefined, + openWizard, + exitWizard, + overview: { snapshot: overviewSnapshot('idle'), send: overviewSend, canConfirmRemove: false }, + wizard: { snapshot: wizardSnapshot('verify-domain'), send: wizardSend }, + domainsStep: buildDomainsStep(), + ...overrides, + }; + + render( + + + , + ); + + return { openWizard, exitWizard, overviewSend, wizardSend }; +} + +describe('OrganizationProfileSecurityPanelView — status badge + entry points', () => { + it('renders the Unconfigured badge and a Start configuration button that forces the first step', () => { + const { openWizard } = renderView({ connection: buildConnection({ status: 'unconfigured' }) }); + + expect(screen.getByText('Unconfigured')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Start configuration' })); + expect(openWizard).toHaveBeenCalledWith(true); + }); + + it('renders the In Progress badge and a Continue configuration button that resumes', () => { + const { openWizard } = renderView({ connection: buildConnection({ hasConnection: true, status: 'in_progress' }) }); + + expect(screen.getByText('In Progress')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Continue configuration' })); + expect(openWizard).toHaveBeenCalledWith(); + }); + + it('renders the Active badge and the configured action buttons', () => { + renderView({ connection: buildConnection({ hasConnection: true, isActive: true, status: 'active' }) }); + + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Deactivate' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + }); + + it('renders the Inactive badge and an Activate action', () => { + renderView({ + connection: buildConnection({ hasConnection: true, hasMinimumConfiguration: true, status: 'inactive' }), + }); + + expect(screen.getByText('Inactive')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Activate' })).toBeInTheDocument(); + }); +}); + +describe('OrganizationProfileSecurityPanelView — configured actions', () => { + const activeConnection = buildConnection({ hasConnection: true, isActive: true, status: 'active' }); + + it('opens the wizard at the first step from Edit', () => { + const { openWizard } = renderView({ connection: activeConnection }); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + expect(openWizard).toHaveBeenCalledWith(true); + }); + + it('sends DEACTIVATE from the active connection', () => { + const { overviewSend } = renderView({ connection: activeConnection }); + fireEvent.click(screen.getByRole('button', { name: 'Deactivate' })); + expect(overviewSend).toHaveBeenCalledWith({ type: 'DEACTIVATE' }); + }); + + it('sends ACTIVATE from an inactive connection', () => { + const { overviewSend } = renderView({ + connection: buildConnection({ hasConnection: true, hasMinimumConfiguration: true, status: 'inactive' }), + }); + fireEvent.click(screen.getByRole('button', { name: 'Activate' })); + expect(overviewSend).toHaveBeenCalledWith({ type: 'ACTIVATE' }); + }); + + it('disables the activate/deactivate action while a mutation is in flight', () => { + renderView({ + connection: activeConnection, + overview: { snapshot: overviewSnapshot('deactivating'), send: vi.fn(), canConfirmRemove: false }, + }); + expect(screen.getByRole('button', { name: 'Deactivate' })).toBeDisabled(); + }); + + it('lists the connection domains as chips', () => { + renderView({ connection: activeConnection, enterpriseConnection: { domains: ['acme.com', 'acme.io'] } }); + expect(screen.getByText('Domains:')).toBeInTheDocument(); + expect(screen.getByText('acme.com')).toBeInTheDocument(); + expect(screen.getByText('acme.io')).toBeInTheDocument(); + }); + + it('surfaces a mutation error in the section alert when the remove dialog is closed', () => { + renderView({ + connection: activeConnection, + overview: { + snapshot: overviewSnapshot('idle', { error: 'Cannot deactivate the only connection' }), + send: vi.fn(), + canConfirmRemove: false, + }, + }); + expect(screen.getByRole('alert')).toHaveTextContent('Cannot deactivate the only connection'); + }); +}); + +describe('OrganizationProfileSecurityPanelView — remove dialog', () => { + const activeConnection = buildConnection({ hasConnection: true, isActive: true, status: 'active' }); + + it('opens the remove flow from the Remove trigger', () => { + const { overviewSend } = renderView({ connection: activeConnection }); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + expect(overviewSend).toHaveBeenCalledWith({ type: 'OPEN_REMOVE' }); + }); + + it('emits a typed confirmation and blocks confirm until it matches', () => { + const overviewSend = vi.fn(); + renderView({ + connection: activeConnection, + overview: { snapshot: overviewSnapshot('confirmingRemove'), send: overviewSend, canConfirmRemove: false }, + }); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Acme Inc' } }); + expect(overviewSend).toHaveBeenCalledWith({ type: 'TYPE_CONFIRMATION', value: 'Acme Inc' }); + expect(screen.getByRole('button', { name: 'Remove connection' })).toBeDisabled(); + }); + + it('confirms removal once the confirmation matches', () => { + const overviewSend = vi.fn(); + renderView({ + connection: activeConnection, + overview: { + snapshot: overviewSnapshot('confirmingRemove', { confirmationValue: 'Acme Inc' }), + send: overviewSend, + canConfirmRemove: true, + }, + }); + + const form = screen.getByRole('textbox').closest('form'); + expect(form).not.toBeNull(); + if (form) { + fireEvent.submit(form); + } + expect(overviewSend).toHaveBeenCalledWith({ type: 'CONFIRM_REMOVE' }); + }); +}); + +describe('OrganizationProfileSecurityPanelView — loading + wizard mode', () => { + it('shows the overview skeleton (not the overview content) while loading in overview mode', () => { + renderView({ mode: 'overview', isLoading: true, connection: buildConnection({ status: 'unconfigured' }) }); + expect(screen.queryByRole('button', { name: 'Start configuration' })).not.toBeInTheDocument(); + }); + + it('keeps the wizard mounted regardless of loading and renders the current step', () => { + renderView({ mode: 'wizard', isLoading: true, wizard: { snapshot: wizardSnapshot('configure'), send: vi.fn() } }); + expect(screen.getByTestId('security-wizard-step')).toHaveTextContent('configure'); + }); + + it('returns to the overview from the wizard back control', () => { + const { exitWizard } = renderView({ mode: 'wizard' }); + fireEvent.click(screen.getByRole('button', { name: 'Back to overview' })); + expect(exitWizard).toHaveBeenCalled(); + }); +}); + +describe('OrganizationProfileSecurityPanelView — wizard navigation + breadcrumb', () => { + it('disables Back on the first step and Continue on the last step', () => { + renderView({ mode: 'wizard', wizard: { snapshot: wizardSnapshot('verify-domain'), send: vi.fn() } }); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Continue' })).not.toBeDisabled(); + }); + + it('sends NEXT and PREV from the bounded nav at a middle step', () => { + const send = vi.fn(); + renderView({ + mode: 'wizard', + wizard: { snapshot: wizardSnapshot('configure', { allDomainsVerified: true }), send }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(send).toHaveBeenCalledWith({ type: 'NEXT' }); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(send).toHaveBeenCalledWith({ type: 'PREV' }); + }); + + it('marks the current breadcrumb step and jumps to a reachable step via GOTO', () => { + const send = vi.fn(); + // Domains verified + configured → configure and test are reachable; sit on configure. + renderView({ + mode: 'wizard', + wizard: { + snapshot: wizardSnapshot('configure', { + allDomainsVerified: true, + hasConnection: true, + hasMinimumConfiguration: true, + }), + send, + }, + }); + + expect(screen.getByRole('button', { name: /Connection/ })).toHaveAttribute('aria-current', 'step'); + fireEvent.click(screen.getByRole('button', { name: /Test/ })); + expect(send).toHaveBeenCalledWith({ type: 'GOTO', step: 'test' }); + }); + + it('disables an unreachable breadcrumb step', () => { + renderView({ mode: 'wizard', wizard: { snapshot: wizardSnapshot('verify-domain'), send: vi.fn() } }); + // Nothing configured → Activate is unreachable. + expect(screen.getByRole('button', { name: /Activate/ })).toBeDisabled(); + }); + + it('ticks completed breadcrumb steps', () => { + renderView({ + mode: 'wizard', + wizard: { snapshot: wizardSnapshot('configure', { allDomainsVerified: true }), send: vi.fn() }, + }); + // verify-domain is complete (all domains verified). + expect(screen.getByRole('button', { name: /Domains ✓/ })).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-add.machine.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-add.machine.test.ts new file mode 100644 index 00000000000..cd5f8848101 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-add.machine.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { tick } from '../../machines/__tests__/test-utils'; +import { + isValidDomain, + organizationProfileSecurityWizardDomainsAddMachine, +} from '../organization-profile-security-wizard-domains-add.machine'; + +function start(createDomain = vi.fn(() => Promise.resolve())) { + const actor = createActor(organizationProfileSecurityWizardDomainsAddMachine, { + context: { createDomain }, + }); + actor.start(); + return { actor, createDomain }; +} + +describe('isValidDomain', () => { + it('accepts bare domains and rejects protocols / single labels / spaces', () => { + expect(isValidDomain('example.com')).toBe(true); + expect(isValidDomain('sub.example.co.uk')).toBe(true); + expect(isValidDomain('EXAMPLE.COM')).toBe(true); + expect(isValidDomain('https://example.com')).toBe(false); + expect(isValidDomain('localhost')).toBe(false); + expect(isValidDomain('example .com')).toBe(false); + }); +}); + +describe('organizationProfileSecurityWizardDomainsAddMachine', () => { + it('tracks the draft as the user types', () => { + const { actor } = start(); + + actor.send({ type: 'TYPE_NAME', value: 'clerk.com' }); + + expect(actor.getSnapshot().context.draftName).toBe('clerk.com'); + }); + + it('creates the submitted domain, then clears the draft on success', async () => { + const { actor, createDomain } = start(); + + actor.send({ type: 'TYPE_NAME', value: 'clerk.com' }); + actor.send({ type: 'SUBMIT', name: 'clerk.com' }); + + expect(actor.getSnapshot().value).toBe('creating'); + expect(createDomain).toHaveBeenCalledWith('clerk.com'); + + await tick(); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.draftName).toBe(''); + expect(actor.getSnapshot().context.error).toBeNull(); + }); + + it('keeps the draft and surfaces the error when creation fails', async () => { + const createDomain = vi.fn(() => Promise.reject(new Error('Domain already exists'))); + const { actor } = start(createDomain); + + actor.send({ type: 'TYPE_NAME', value: 'clerk.com' }); + actor.send({ type: 'SUBMIT', name: 'clerk.com' }); + + await tick(); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.draftName).toBe('clerk.com'); + expect(actor.getSnapshot().context.error).toBe('Domain already exists'); + }); + + it('creates a suggested domain without touching the draft', async () => { + const { actor, createDomain } = start(); + + actor.send({ type: 'TYPE_NAME', value: 'typed.com' }); + actor.send({ type: 'SUBMIT', name: 'suggested.com' }); + + expect(createDomain).toHaveBeenCalledWith('suggested.com'); + + await tick(); + + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('refuses to submit an invalid domain', () => { + const { actor, createDomain } = start(); + + actor.send({ type: 'SUBMIT', name: 'not a domain' }); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(createDomain).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-prepare.machine.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-prepare.machine.test.ts new file mode 100644 index 00000000000..1c2e3749f69 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-prepare.machine.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { tick } from '../../machines/__tests__/test-utils'; +import { organizationProfileSecurityWizardDomainsPrepareMachine } from '../organization-profile-security-wizard-domains-prepare.machine'; + +function start(prepareVerification = vi.fn(() => Promise.resolve())) { + const actor = createActor(organizationProfileSecurityWizardDomainsPrepareMachine, { + context: { prepareVerification }, + }); + actor.start(); + return { actor, prepareVerification }; +} + +describe('organizationProfileSecurityWizardDomainsPrepareMachine', () => { + it('re-prepares the chosen domain, then returns to idle', async () => { + const { actor, prepareVerification } = start(); + + actor.send({ type: 'PREPARE', domainId: 'dmn_1' }); + + expect(actor.getSnapshot().value).toBe('preparing'); + expect(actor.getSnapshot().context.pendingDomainId).toBe('dmn_1'); + expect(prepareVerification).toHaveBeenCalledWith('dmn_1'); + + await tick(); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.pendingDomainId).toBe(''); + expect(actor.getSnapshot().context.error).toBeNull(); + }); + + it('surfaces the error and clears the pending domain when prepare fails', async () => { + const prepareVerification = vi.fn(() => Promise.reject(new Error('DNS lookup failed'))); + const { actor } = start(prepareVerification); + + actor.send({ type: 'PREPARE', domainId: 'dmn_1' }); + + await tick(); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.pendingDomainId).toBe(''); + expect(actor.getSnapshot().context.error).toBe('DNS lookup failed'); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-remove.machine.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-remove.machine.test.ts new file mode 100644 index 00000000000..82cdb49ac4b --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains-remove.machine.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { tick } from '../../machines/__tests__/test-utils'; +import { organizationProfileSecurityWizardDomainsRemoveMachine } from '../organization-profile-security-wizard-domains-remove.machine'; + +function start(removeDomain = vi.fn(() => Promise.resolve())) { + const actor = createActor(organizationProfileSecurityWizardDomainsRemoveMachine, { + context: { removeDomain }, + }); + actor.start(); + return { actor, removeDomain }; +} + +describe('organizationProfileSecurityWizardDomainsRemoveMachine', () => { + it('opens the confirmation for the chosen domain, carrying the active flag', () => { + const { actor } = start(); + + actor.send({ type: 'OPEN', domainName: 'clerk.com', isConnectionActive: true }); + + expect(actor.getSnapshot().value).toBe('confirming'); + expect(actor.getSnapshot().context.domainName).toBe('clerk.com'); + expect(actor.getSnapshot().context.isConnectionActive).toBe(true); + }); + + it('removes the opened domain on confirm, then returns to idle', async () => { + const { actor, removeDomain } = start(); + + actor.send({ type: 'OPEN', domainName: 'clerk.com', isConnectionActive: false }); + actor.send({ type: 'CONFIRM' }); + + expect(actor.getSnapshot().value).toBe('deleting'); + expect(removeDomain).toHaveBeenCalledWith('clerk.com'); + + await tick(); + + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('returns to confirming with an error when removal fails', async () => { + const removeDomain = vi.fn(() => Promise.reject(new Error('nope'))); + const { actor } = start(removeDomain); + + actor.send({ type: 'OPEN', domainName: 'clerk.com', isConnectionActive: false }); + actor.send({ type: 'CONFIRM' }); + + await tick(); + + expect(actor.getSnapshot().value).toBe('confirming'); + expect(actor.getSnapshot().context.error).toBe('nope'); + }); + + it('cancels back to idle without removing', () => { + const { actor, removeDomain } = start(); + + actor.send({ type: 'OPEN', domainName: 'clerk.com', isConnectionActive: false }); + actor.send({ type: 'CANCEL' }); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(removeDomain).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains.view.test.tsx b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains.view.test.tsx new file mode 100644 index 00000000000..a1129898086 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-domains.view.test.tsx @@ -0,0 +1,208 @@ +import type { OrganizationDomainResource } from '@clerk/shared/types'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { Snapshot } from '../../machine/types'; +import { MosaicProvider } from '../../MosaicProvider'; +import type { OrganizationProfileSecurityWizardDomainsAddContext } from '../organization-profile-security-wizard-domains-add.machine'; +import type { OrganizationProfileSecurityWizardDomainsPrepareContext } from '../organization-profile-security-wizard-domains-prepare.machine'; +import type { OrganizationProfileSecurityWizardDomainsRemoveContext } from '../organization-profile-security-wizard-domains-remove.machine'; +import type { OrganizationProfileSecurityWizardDomainsViewProps } from '../organization-profile-security-wizard-domains.view'; +import { OrganizationProfileSecurityWizardDomainsView } from '../organization-profile-security-wizard-domains.view'; + +const buildDomain = (overrides: { + id: string; + name: string; + status?: 'verified' | 'expired' | 'unverified'; + txtRecordName?: string; + txtRecordValue?: string; +}): OrganizationDomainResource => { + const { id, name, status = 'unverified', txtRecordName, txtRecordValue } = overrides; + // SAFETY: the view reads only id / name / ownershipVerification.{status,txtRecordName,txtRecordValue}. + // A full OrganizationDomainResource carries dozens of unrelated fields; a test fixture narrows to + // what the view touches. This cast is confined to the test's fixture builder. + return { + id, + name, + ownershipVerification: + status === 'unverified' ? { status: 'unverified', txtRecordName, txtRecordValue } : { status }, + } as unknown as OrganizationDomainResource; +}; + +const addSnapshot = ( + value: string, + context: Partial = {}, +): Snapshot => ({ + value, + status: 'active', + context: { draftName: '', pendingName: '', error: null, createDomain: async () => {}, ...context }, +}); + +const prepareSnapshot = ( + value: string, + context: Partial = {}, +): Snapshot => ({ + value, + status: 'active', + context: { pendingDomainId: '', error: null, prepareVerification: async () => {}, ...context }, +}); + +const removeSnapshot = ( + value: string, + context: Partial = {}, +): Snapshot => ({ + value, + status: 'active', + context: { domainName: '', isConnectionActive: false, removeDomain: async () => {}, error: null, ...context }, +}); + +function renderView(overrides: Partial = {}) { + const addSend = vi.fn(); + const prepareSend = vi.fn(); + const removeSend = vi.fn(); + const onStepMounted = vi.fn(); + + const props: OrganizationProfileSecurityWizardDomainsViewProps = { + domains: [], + suggestedDomain: null, + hasConnection: false, + isConnectionActive: false, + add: { snapshot: addSnapshot('idle'), send: addSend }, + prepare: { snapshot: prepareSnapshot('idle'), send: prepareSend }, + remove: { snapshot: removeSnapshot('idle'), send: removeSend }, + onStepMounted, + ...overrides, + }; + + render( + + + , + ); + + return { addSend, prepareSend, removeSend, onStepMounted }; +} + +describe('OrganizationProfileSecurityWizardDomainsView — add field', () => { + it('fires TYPE_NAME as the user types', () => { + const { addSend } = renderView(); + fireEvent.change(screen.getByRole('textbox', { name: 'Domain' }), { target: { value: 'clerk.com' } }); + expect(addSend).toHaveBeenCalledWith({ type: 'TYPE_NAME', value: 'clerk.com' }); + }); + + it('submits a valid, non-duplicate domain', () => { + const send = vi.fn(); + renderView({ add: { snapshot: addSnapshot('idle', { draftName: 'clerk.com' }), send } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + expect(send).toHaveBeenCalledWith({ type: 'SUBMIT', name: 'clerk.com' }); + }); + + it('disables Add for an invalid domain', () => { + renderView({ add: { snapshot: addSnapshot('idle', { draftName: 'not a domain' }), send: vi.fn() } }); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + }); + + it('disables Add for a duplicate domain already in the list', () => { + renderView({ + domains: [buildDomain({ id: 'd1', name: 'clerk.com', status: 'verified' })], + add: { snapshot: addSnapshot('idle', { draftName: 'clerk.com' }), send: vi.fn() }, + }); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + }); +}); + +describe('OrganizationProfileSecurityWizardDomainsView — suggestion + error', () => { + it('shows the suggestion only while the list is empty and submits it', () => { + const send = vi.fn(); + renderView({ suggestedDomain: 'clerk.com', add: { snapshot: addSnapshot('idle'), send } }); + fireEvent.click(screen.getByRole('button', { name: 'Add clerk.com' })); + expect(send).toHaveBeenCalledWith({ type: 'SUBMIT', name: 'clerk.com' }); + }); + + it('hides the suggestion once domains exist', () => { + renderView({ + suggestedDomain: 'clerk.com', + domains: [buildDomain({ id: 'd1', name: 'clerk.com' })], + }); + expect(screen.queryByRole('button', { name: 'Add clerk.com' })).not.toBeInTheDocument(); + }); + + it('surfaces the add error, then the prepare error, in the shared alert', () => { + renderView({ add: { snapshot: addSnapshot('idle', { error: 'Domain already exists' }), send: vi.fn() } }); + expect(screen.getByRole('alert')).toHaveTextContent('Domain already exists'); + }); + + it('shows the prepare error when there is no add error', () => { + renderView({ prepare: { snapshot: prepareSnapshot('idle', { error: 'DNS lookup failed' }), send: vi.fn() } }); + expect(screen.getByRole('alert')).toHaveTextContent('DNS lookup failed'); + }); +}); + +describe('OrganizationProfileSecurityWizardDomainsView — domain list', () => { + it('renders each domain with its status', () => { + renderView({ + domains: [ + buildDomain({ id: 'd1', name: 'verified.com', status: 'verified' }), + buildDomain({ id: 'd2', name: 'expired.com', status: 'expired' }), + buildDomain({ id: 'd3', name: 'pending.com', status: 'unverified', txtRecordValue: 'clerk-xyz' }), + ], + }); + expect(screen.getByText('verified.com')).toBeInTheDocument(); + expect(screen.getByText('Verified')).toBeInTheDocument(); + expect(screen.getByText('Expired')).toBeInTheDocument(); + expect(screen.getByText('clerk-xyz')).toBeInTheDocument(); + }); + + it('re-prepares an expired domain via PREPARE', () => { + const send = vi.fn(); + renderView({ + domains: [buildDomain({ id: 'd2', name: 'expired.com', status: 'expired' })], + prepare: { snapshot: prepareSnapshot('idle'), send }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Verify again' })); + expect(send).toHaveBeenCalledWith({ type: 'PREPARE', domainId: 'd2' }); + }); + + it('opens the remove dialog with the domain name and active flag', () => { + const send = vi.fn(); + renderView({ + domains: [buildDomain({ id: 'd1', name: 'clerk.com', status: 'verified' })], + isConnectionActive: true, + remove: { snapshot: removeSnapshot('idle'), send }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Remove clerk.com' })); + expect(send).toHaveBeenCalledWith({ type: 'OPEN', domainName: 'clerk.com', isConnectionActive: true }); + }); + + it('locks the last verified domain from removal while a connection exists', () => { + renderView({ + domains: [buildDomain({ id: 'd1', name: 'clerk.com', status: 'verified' })], + hasConnection: true, + }); + expect(screen.getByRole('button', { name: 'Remove clerk.com' })).toBeDisabled(); + }); + + it('allows removing a verified domain when there is no connection', () => { + renderView({ + domains: [buildDomain({ id: 'd1', name: 'clerk.com', status: 'verified' })], + hasConnection: false, + }); + expect(screen.getByRole('button', { name: 'Remove clerk.com' })).not.toBeDisabled(); + }); +}); + +describe('OrganizationProfileSecurityWizardDomainsView — remove dialog + telemetry', () => { + it('confirms and cancels removal', () => { + const send = vi.fn(); + renderView({ remove: { snapshot: removeSnapshot('confirming', { domainName: 'clerk.com' }), send } }); + fireEvent.click(screen.getByRole('button', { name: 'Remove domain' })); + expect(send).toHaveBeenCalledWith({ type: 'CONFIRM' }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(send).toHaveBeenCalledWith({ type: 'CANCEL' }); + }); + + it('records the mount telemetry exactly once', () => { + const { onStepMounted } = renderView(); + expect(onStepMounted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard.equivalence.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard.equivalence.test.ts new file mode 100644 index 00000000000..dd86e467fcc --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard.equivalence.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; + +import { + initialState as legacyInitialState, + reduce as legacyReduce, + type WizardConfig, + type WizardEvent, +} from '@/components/ConfigureSSO/elements/Wizard/reducer'; + +import { createActor } from '../../machine/createActor'; +import type { OrganizationProfileSecurityWizardContext } from '../organization-profile-security-wizard.machine'; +import { + organizationProfileSecurityWizardMachine, + SECURITY_WIZARD_STEP_ORDER, +} from '../organization-profile-security-wizard.machine'; + +/** + * 1:1 equivalence proof for the ConfigureSSO wizard migration. + * + * This drives IDENTICAL event sequences through the legacy pure reducer + * (`components/ConfigureSSO/elements/Wizard/reducer.ts`, the "older machine" the + * migration is modeled on) and the new Mosaic security wizard machine, and asserts + * the resulting navigation state matches exactly — current step, direction, and + * hasNavigated — across every reachability combination of the three gated steps. + * + * The legacy reducer is generic/descriptor-driven; the new machine is the + * security-specific instance. To hold them in lockstep, a single `Reach` spec seeds + * BOTH: the legacy step descriptors' `isReachable` closures, and the new machine's + * connection-entity context booleans (which its reachability guards read). The + * mapping mirrors `ConfigureSSOWizard.tsx`: + * configure reachable ⟺ allDomainsVerified || hasConnection + * test reachable ⟺ hasMinimumConfiguration || isActive + * activate reachable ⟺ hasSuccessfulTestRun || isActive + * + * Note on the deferred advance: the legacy *reducer* treats a blocked NEXT as a + * pure no-op (current unchanged); the new machine PARKS it (current still + * unchanged, but `pendingNext` flips). That matches the legacy *seam* + * (`useWizardMachine`'s `pendingNextFrom`), not the bare reducer — so the parked + * NEXT leaves `current`/`direction`/`hasNavigated` identical to the reducer, and + * the parity assertions below hold. The park→recheck resolution is covered by the + * machine's own test. + */ + +interface Reach { + configure: boolean; + test: boolean; + activate: boolean; +} + +const legacyConfig = (reach: Reach): WizardConfig => ({ + descriptors: [ + { id: 'verify-domain' }, + { id: 'configure', isReachable: () => reach.configure }, + { id: 'test', isReachable: () => reach.test }, + { id: 'activate', isReachable: () => reach.activate }, + ], +}); + +const machineContext = (reach: Reach): Partial => ({ + allDomainsVerified: reach.configure, + hasMinimumConfiguration: reach.test, + hasSuccessfulTestRun: reach.activate, + hasConnection: false, + isActive: false, +}); + +interface NavState { + current: string; + direction: 1 | -1 | 0; + hasNavigated: boolean; +} + +const runLegacy = (reach: Reach, events: WizardEvent[]): NavState[] => { + const config = legacyConfig(reach); + let state = legacyInitialState(config); + const trace: NavState[] = [{ current: state.current, direction: state.direction, hasNavigated: state.hasNavigated }]; + for (const event of events) { + state = legacyReduce(state, event, config); + trace.push({ current: state.current, direction: state.direction, hasNavigated: state.hasNavigated }); + } + return trace; +}; + +const runMachine = (reach: Reach, events: WizardEvent[]): NavState[] => { + const actor = createActor(organizationProfileSecurityWizardMachine, { context: machineContext(reach) }); + actor.start(); + const read = (): NavState => { + const snap = actor.getSnapshot(); + return { current: snap.value, direction: snap.context.direction, hasNavigated: snap.context.hasNavigated }; + }; + const trace: NavState[] = [read()]; + for (const event of events) { + actor.send(event); + trace.push(read()); + } + return trace; +}; + +/** All 8 reachability combinations of the three gated steps. */ +const ALL_REACH: Reach[] = [false, true].flatMap(configure => + [false, true].flatMap(test => [false, true].map(activate => ({ configure, test, activate }))), +); + +/** Representative event sequences: linear, backtracking, and jumping. */ +const SEQUENCES: { name: string; events: WizardEvent[] }[] = [ + { name: 'all NEXT', events: [{ type: 'NEXT' }, { type: 'NEXT' }, { type: 'NEXT' }, { type: 'NEXT' }] }, + { name: 'all PREV', events: [{ type: 'PREV' }, { type: 'PREV' }, { type: 'PREV' }, { type: 'PREV' }] }, + { + name: 'next then back', + events: [{ type: 'NEXT' }, { type: 'NEXT' }, { type: 'PREV' }, { type: 'NEXT' }], + }, + { + name: 'goto forward then backward', + events: [ + { type: 'GOTO', step: 'activate' }, + { type: 'GOTO', step: 'verify-domain' }, + { type: 'GOTO', step: 'test' }, + ], + }, + { + name: 'blocked next then goto then prev', + events: [{ type: 'NEXT' }, { type: 'GOTO', step: 'test' }, { type: 'PREV' }, { type: 'GOTO', step: 'nope' }], + }, +]; + +describe('security wizard — 1:1 equivalence with the legacy reducer', () => { + it('derives the same initial step for every reachability combination', () => { + for (const reach of ALL_REACH) { + const legacyInitial = legacyInitialState(legacyConfig(reach)).current; + const machineInitial = runMachine(reach, [])[0].current; + expect(machineInitial, JSON.stringify(reach)).toBe(legacyInitial); + } + }); + + it('produces identical navigation traces across every reach × sequence', () => { + for (const reach of ALL_REACH) { + for (const { name, events } of SEQUENCES) { + const legacy = runLegacy(reach, events); + const machine = runMachine(reach, events); + expect(machine, `${name} @ ${JSON.stringify(reach)}`).toEqual(legacy); + } + } + }); + + it('covers the full declared step set', () => { + expect(SECURITY_WIZARD_STEP_ORDER).toEqual(['verify-domain', 'configure', 'test', 'activate']); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard.machine.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard.machine.test.ts new file mode 100644 index 00000000000..f37d6086d94 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard.machine.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import type { OrganizationProfileSecurityWizardContext } from '../organization-profile-security-wizard.machine'; +import { organizationProfileSecurityWizardMachine } from '../organization-profile-security-wizard.machine'; + +type Reach = Partial< + Pick< + OrganizationProfileSecurityWizardContext, + 'allDomainsVerified' | 'hasConnection' | 'hasMinimumConfiguration' | 'isActive' | 'hasSuccessfulTestRun' + > +>; + +const ALL_REACHABLE: Reach = { + allDomainsVerified: true, + hasConnection: true, + hasMinimumConfiguration: true, + isActive: true, + hasSuccessfulTestRun: true, +}; + +/** Start an actor from its derived initial step, with the given reachability context. */ +const startFrom = (reach: Reach) => { + const actor = createActor(organizationProfileSecurityWizardMachine, { context: reach }); + actor.start(); + return actor; +}; + +/** Teleport an actor to a specific step (inert), with the given reachability context. */ +const actorAt = (value: string, reach: Reach) => + createActor(organizationProfileSecurityWizardMachine, { context: reach, snapshot: { value } }); + +describe('organizationProfileSecurityWizardMachine — derived initial (furthest reachable)', () => { + it('nothing reachable → verify-domain', () => { + expect(startFrom({}).getSnapshot().value).toBe('verify-domain'); + }); + + it('domains verified → configure', () => { + expect(startFrom({ allDomainsVerified: true }).getSnapshot().value).toBe('configure'); + }); + + it('configured → test', () => { + expect( + startFrom({ allDomainsVerified: true, hasConnection: true, hasMinimumConfiguration: true }).getSnapshot().value, + ).toBe('test'); + }); + + it('active connection → activate (last step)', () => { + expect(startFrom(ALL_REACHABLE).getSnapshot().value).toBe('activate'); + }); + + it('stops at the first closed gate (configured but no successful test run → test)', () => { + expect( + startFrom({ + allDomainsVerified: true, + hasConnection: true, + hasMinimumConfiguration: true, + hasSuccessfulTestRun: false, + }).getSnapshot().value, + ).toBe('test'); + }); + + it('seeds direction 0 and hasNavigated false', () => { + const snap = startFrom({ allDomainsVerified: true }).getSnapshot(); + expect(snap.context.direction).toBe(0); + expect(snap.context.hasNavigated).toBe(false); + }); +}); + +describe('organizationProfileSecurityWizardMachine — sequential NEXT/PREV/GOTO', () => { + it('advances one slot when the next guard holds', () => { + const actor = actorAt('verify-domain', ALL_REACHABLE); + actor.send({ type: 'NEXT' }); + const snap = actor.getSnapshot(); + expect(snap.value).toBe('configure'); + expect(snap.context.direction).toBe(1); + expect(snap.context.hasNavigated).toBe(true); + }); + + it('does not skip a satisfied step (verify-domain → configure, not test)', () => { + const actor = actorAt('verify-domain', ALL_REACHABLE); + actor.send({ type: 'NEXT' }); + expect(actor.getSnapshot().value).toBe('configure'); + }); + + it('walks one slot back on PREV', () => { + const actor = actorAt('test', ALL_REACHABLE); + actor.send({ type: 'PREV' }); + const snap = actor.getSnapshot(); + expect(snap.value).toBe('configure'); + expect(snap.context.direction).toBe(-1); + }); + + it('GOTO jumps to a reachable target', () => { + const actor = actorAt('verify-domain', ALL_REACHABLE); + actor.send({ type: 'GOTO', step: 'test' }); + expect(actor.getSnapshot().value).toBe('test'); + expect(actor.getSnapshot().context.direction).toBe(0); + }); +}); + +describe('organizationProfileSecurityWizardMachine — no-op identity (same ref, no notify)', () => { + const expectNoOp = (value: string, reach: Reach, event: { type: 'NEXT' | 'PREV' | 'GOTO'; step?: string }) => { + const actor = actorAt(value, reach); + const before = actor.getSnapshot(); + const seen: string[] = []; + actor.subscribe(s => seen.push(s.value)); + // @ts-expect-error GOTO needs step; the others don't — fine for the test call + actor.send(event); + expect(actor.getSnapshot()).toBe(before); + expect(seen).toEqual([]); + }; + + it('NEXT at the terminal step is a no-op', () => { + expectNoOp('activate', ALL_REACHABLE, { type: 'NEXT' }); + }); + + it('PREV at the first step is a no-op', () => { + expectNoOp('verify-domain', ALL_REACHABLE, { type: 'PREV' }); + }); + + it('GOTO to an unreachable target is a no-op', () => { + // No connection/domains → configure/test/activate all closed. + expectNoOp('verify-domain', {}, { type: 'GOTO', step: 'activate' }); + }); + + it('GOTO to the current step is a no-op', () => { + expectNoOp('verify-domain', ALL_REACHABLE, { type: 'GOTO', step: 'verify-domain' }); + }); +}); + +describe('organizationProfileSecurityWizardMachine — recheck re-seats when a guard breaks', () => { + it('re-seats to the furthest still-reachable step when the current step becomes unreachable', () => { + const actor = actorAt('test', { allDomainsVerified: true, hasConnection: true, hasMinimumConfiguration: true }); + expect(actor.getSnapshot().value).toBe('test'); + + // The connection backing `test` is removed elsewhere, but the domains stay verified, + // so `configure` is still reachable — re-seat there, not all the way back. + actor.setContext({ hasConnection: false, hasMinimumConfiguration: false }); + actor.recheck(); + expect(actor.getSnapshot().value).toBe('configure'); + }); + + it('re-seats all the way to verify-domain when every later guard breaks', () => { + const actor = actorAt('configure', { allDomainsVerified: true, hasConnection: true }); + expect(actor.getSnapshot().value).toBe('configure'); + + actor.setContext({ allDomainsVerified: false, hasConnection: false }); + actor.recheck(); + expect(actor.getSnapshot().value).toBe('verify-domain'); + }); +}); + +describe('organizationProfileSecurityWizardMachine — deferred advance (parked NEXT resolved by recheck)', () => { + it('parks a NEXT blocked by a not-yet-open guard, then advances when data lands', () => { + // On verify-domain with nothing reachable: NEXT to configure is blocked. + const actor = actorAt('verify-domain', {}); + actor.send({ type: 'NEXT' }); + expect(actor.getSnapshot().value).toBe('verify-domain'); + expect(actor.getSnapshot().context.pendingNext).toBe(true); + + // The awaited create/verify revalidate lands; the controller reseats context + rechecks. + actor.setContext({ allDomainsVerified: true }); + actor.recheck(); + expect(actor.getSnapshot().value).toBe('configure'); + expect(actor.getSnapshot().context.pendingNext).toBe(false); + expect(actor.getSnapshot().context.direction).toBe(1); + }); + + it('an explicit GOTO abandons a parked advance', () => { + const actor = actorAt('verify-domain', { + allDomainsVerified: true, + hasConnection: true, + hasMinimumConfiguration: true, + }); + // configure is reachable, so NEXT advances immediately — park a blocked one instead: + const blocked = actorAt('verify-domain', {}); + blocked.send({ type: 'NEXT' }); + expect(blocked.getSnapshot().context.pendingNext).toBe(true); + blocked.setContext({ allDomainsVerified: true }); + // A GOTO clears the parked advance (navigated(0) resets pendingNext) before recheck. + blocked.send({ type: 'GOTO', step: 'configure' }); + expect(blocked.getSnapshot().value).toBe('configure'); + expect(blocked.getSnapshot().context.pendingNext).toBe(false); + // sanity: `actor` above just confirms a direct reachable NEXT path exists + actor.send({ type: 'NEXT' }); + expect(actor.getSnapshot().value).toBe('configure'); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile.test.tsx b/packages/ui/src/mosaic/organization/__tests__/organization-profile.test.tsx index 2bcfd817d51..4576ac43095 100644 --- a/packages/ui/src/mosaic/organization/__tests__/organization-profile.test.tsx +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile.test.tsx @@ -4,6 +4,7 @@ import { OrganizationProfile } from '../organization-profile'; import { OrganizationProfileDeleteSection } from '../organization-profile-delete-section'; import { OrganizationProfileGeneralPanel } from '../organization-profile-general-panel'; import { OrganizationProfileLeaveSection } from '../organization-profile-leave-section'; +import { OrganizationProfileSecurityPanel } from '../organization-profile-security-panel'; describe('OrganizationProfile compound parts', () => { it('exposes the general panel as a standalone part', () => { @@ -17,4 +18,8 @@ describe('OrganizationProfile compound parts', () => { it('exposes the delete section as a standalone part', () => { expect(OrganizationProfile.DeleteSection).toBe(OrganizationProfileDeleteSection); }); + + it('exposes the security panel as a standalone part', () => { + expect(OrganizationProfile.SecurityPanel).toBe(OrganizationProfileSecurityPanel); + }); }); diff --git a/packages/ui/src/mosaic/organization/organization-profile-security-panel-overview.machine.ts b/packages/ui/src/mosaic/organization/organization-profile-security-panel-overview.machine.ts new file mode 100644 index 00000000000..ca5745a7f26 --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-security-panel-overview.machine.ts @@ -0,0 +1,121 @@ +import { setup } from '../machine/setup'; + +/** + * The overview flow for a *configured* enterprise SSO connection: the two mutating + * actions the legacy `SecuritySsoSection` three-dot menu exposes — activate / + * deactivate (`setConnectionActive`) and remove (`deleteConnection`, behind the + * type-to-confirm reset dialog). + * + * Status / badge / domain-chips are NOT states here — they are derived by the view + * from the connection entity the controller passes in; revalidation after a mutation + * drives the settled UI. Only the async actions and the remove confirmation are flow. + * + * Mutations are injected as plain, id-bound functions from the controller (the Clerk + * adapter), so this machine stays Clerk-free and testable in plain JS — mirroring + * `organization-profile-delete-section.machine.ts`. + */ +export interface OrganizationProfileSecurityPanelOverviewContext { + /** The org name the remove dialog requires typed to confirm. */ + organizationName: string; + /** What the user has typed into the remove dialog's confirmation field. */ + confirmationValue: string; + activateConnection: () => Promise; + deactivateConnection: () => Promise; + removeConnection: () => Promise; + error: string | null; +} + +export type OrganizationProfileSecurityPanelOverviewEvent = + | { type: 'ACTIVATE' } + | { type: 'DEACTIVATE' } + | { type: 'OPEN_REMOVE' } + | { type: 'TYPE_CONFIRMATION'; value: string } + | { type: 'CONFIRM_REMOVE' } + | { type: 'CANCEL_REMOVE' }; + +/** + * The machine stores whatever `.message` its injected mutation throws. The + * controller (the Clerk adapter) is responsible for reproducing legacy's + * `handleError(err, [], setError)` global-error extraction — it catches the Clerk + * error, pulls the first *global* API message, and re-throws a normalized `Error` + * so this layer stays Clerk-free and testable in plain JS (mirroring + * `organization-profile-delete-section.machine.ts`). + */ +const toErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : 'Something went wrong. Please try again.'; + +const { createMachine, assign, fromPromise } = setup< + OrganizationProfileSecurityPanelOverviewContext, + OrganizationProfileSecurityPanelOverviewEvent +>(); + +export const organizationProfileSecurityPanelOverviewMachine = createMachine({ + id: 'securityOverview', + initial: 'idle', + context: { + organizationName: '', + confirmationValue: '', + activateConnection: async () => {}, + deactivateConnection: async () => {}, + removeConnection: async () => {}, + error: null, + }, + states: { + idle: { + on: { + ACTIVATE: 'activating', + DEACTIVATE: 'deactivating', + OPEN_REMOVE: 'confirmingRemove', + }, + }, + // activating / deactivating are only reachable from `idle`, so the state itself + // single-flights the mutation — the legacy `card.isLoading` guard is unnecessary. + activating: { + invoke: fromPromise(ctx => ctx.activateConnection(), { + onDone: { target: 'idle', actions: assign(() => ({ error: null })) }, + onError: { + target: 'idle', + actions: assign((_, event) => ({ error: toErrorMessage(event.error) })), + }, + }), + }, + deactivating: { + invoke: fromPromise(ctx => ctx.deactivateConnection(), { + onDone: { target: 'idle', actions: assign(() => ({ error: null })) }, + onError: { + target: 'idle', + actions: assign((_, event) => ({ error: toErrorMessage(event.error) })), + }, + }), + }, + confirmingRemove: { + on: { + TYPE_CONFIRMATION: { + actions: assign((_, event) => ({ confirmationValue: event.value, error: null })), + }, + CONFIRM_REMOVE: { + target: 'removing', + guard: context => context.confirmationValue === context.organizationName, + }, + // Reset-on-close: legacy's dialog unmounts and re-initializes an empty field, + // so close→reopen always starts blank. Clear the typed value + error here. + CANCEL_REMOVE: { + target: 'idle', + actions: assign(() => ({ confirmationValue: '', error: null })), + }, + }, + }, + // Removing does NOT end in a `final` state: deleting the connection flips the + // refreshed entity back to `unconfigured` and the user can start over, so the + // overview must stay live. Return to `idle` with the confirmation + error cleared. + removing: { + invoke: fromPromise(ctx => ctx.removeConnection(), { + onDone: { target: 'idle', actions: assign(() => ({ confirmationValue: '', error: null })) }, + onError: { + target: 'confirmingRemove', + actions: assign((_, event) => ({ error: toErrorMessage(event.error) })), + }, + }), + }, + }, +}); diff --git a/packages/ui/src/mosaic/organization/organization-profile-security-panel.controller.tsx b/packages/ui/src/mosaic/organization/organization-profile-security-panel.controller.tsx new file mode 100644 index 00000000000..1a53fb21d5a --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-security-panel.controller.tsx @@ -0,0 +1,357 @@ +import { useClerk, useOrganization, useSession } from '@clerk/shared/react'; +import { eventFlowStepMounted } from '@clerk/shared/telemetry'; +import type { EnterpriseConnectionResource, OrganizationDomainResource } from '@clerk/shared/types'; +import { useCallback, useEffect, useState } from 'react'; + +import type { OrganizationEnterpriseConnection } from '@/components/ConfigureSSO/domain/organizationEnterpriseConnection'; +import { areAllOrganizationDomainsVerified } from '@/components/ConfigureSSO/domain/organizationEnterpriseConnection'; +import { useOrganizationEnterpriseConnection } from '@/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; +import { getClerkAPIErrorMessage, getFieldError, getGlobalError } from '@/utils/errorHandler'; + +import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment'; +import type { Snapshot } from '../machine/types'; +import { useMachine } from '../machine/useMachine'; +import type { + OrganizationProfileSecurityPanelOverviewContext, + OrganizationProfileSecurityPanelOverviewEvent, +} from './organization-profile-security-panel-overview.machine'; +import { organizationProfileSecurityPanelOverviewMachine } from './organization-profile-security-panel-overview.machine'; +import type { + OrganizationProfileSecurityWizardContext, + OrganizationProfileSecurityWizardEvent, +} from './organization-profile-security-wizard.machine'; +import { organizationProfileSecurityWizardMachine } from './organization-profile-security-wizard.machine'; +import type { + OrganizationProfileSecurityWizardDomainsAddContext, + OrganizationProfileSecurityWizardDomainsAddEvent, +} from './organization-profile-security-wizard-domains-add.machine'; +import { organizationProfileSecurityWizardDomainsAddMachine } from './organization-profile-security-wizard-domains-add.machine'; +import type { + OrganizationProfileSecurityWizardDomainsPrepareContext, + OrganizationProfileSecurityWizardDomainsPrepareEvent, +} from './organization-profile-security-wizard-domains-prepare.machine'; +import { organizationProfileSecurityWizardDomainsPrepareMachine } from './organization-profile-security-wizard-domains-prepare.machine'; +import type { + OrganizationProfileSecurityWizardDomainsRemoveContext, + OrganizationProfileSecurityWizardDomainsRemoveEvent, +} from './organization-profile-security-wizard-domains-remove.machine'; +import { organizationProfileSecurityWizardDomainsRemoveMachine } from './organization-profile-security-wizard-domains-remove.machine'; + +/** + * Reproduces the legacy `handleError(err, [], setError)` global-error extraction the overview + * card used, but re-throws a normalized `Error` so the machine layer stays Clerk-free (it only + * stores `error.message`). The first *global* Clerk API message is what the legacy card surfaced; + * a non-Clerk error passes through unchanged so its own message shows. + */ +const toConnectionError = (err: unknown): Error => { + if (err instanceof Error) { + const globalError = getGlobalError(err); + return globalError ? new Error(getClerkAPIErrorMessage(globalError)) : err; + } + return new Error('Something went wrong. Please try again.'); +}; + +/** + * The verify-domain step's error extraction: field-first, then global — mirroring the legacy + * `getFieldError(err) ?? getGlobalError(err)` the domain create / prepare handlers used, unlike + * the global-only overview flow. Normalized to a plain `Error` so the machine stays Clerk-free. + */ +const toDomainError = (err: unknown): Error => { + if (err instanceof Error) { + const apiError = getFieldError(err) ?? getGlobalError(err); + return apiError ? new Error(getClerkAPIErrorMessage(apiError)) : err; + } + return new Error('Something went wrong. Please try again.'); +}; + +/** The user's primary-email domain, offered as a one-click add (legacy `DomainSuggestion`). */ +const emailDomain = (email: string | undefined): string | null => email?.split('@')[1]?.trim().toLowerCase() || null; + +interface OrganizationProfileSecurityOverviewFlow { + snapshot: Snapshot; + send: (event: OrganizationProfileSecurityPanelOverviewEvent) => void; + /** Whether the typed confirmation currently matches the org name (the remove guard). */ + canConfirmRemove: boolean; +} + +interface OrganizationProfileSecurityWizardFlow { + snapshot: Snapshot; + send: (event: OrganizationProfileSecurityWizardEvent) => void; +} + +/** The verify-domain step's three concurrent mutation flows plus the data the step renders. */ +export interface OrganizationProfileSecurityWizardDomainsStep { + domains: OrganizationDomainResource[] | undefined; + suggestedDomain: string | null; + hasConnection: boolean; + isConnectionActive: boolean; + add: { + snapshot: Snapshot; + send: (event: OrganizationProfileSecurityWizardDomainsAddEvent) => void; + }; + prepare: { + snapshot: Snapshot; + send: (event: OrganizationProfileSecurityWizardDomainsPrepareEvent) => void; + }; + remove: { + snapshot: Snapshot; + send: (event: OrganizationProfileSecurityWizardDomainsRemoveEvent) => void; + }; + onStepMounted: () => void; +} + +/** + * The gate that decides whether the Security panel (and, in the shell, its tab) is + * shown. Reproduces legacy's page gate (`OrganizationProfileRoutes.tsx` + + * `OrganizationProfileNavbar.tsx`): the enterprise-SSO feature flag + * (`environment.userSettings.enterpriseSSO.self_serve_sso && organization.selfServeSSOEnabled`) + * and the `org:sys_entconns:manage` permission. It waits for the organization, session, + * AND environment to all resolve before deciding `hidden`, so the tab never flash-hides. + * + * Exported so the shell can gate the Security *tab* on exactly the same decision the + * panel gates its body on (single source of truth — no duplicated branch). + */ +export type OrganizationProfileSecurityGate = + | { status: 'loading' } + | { status: 'hidden' } + | { status: 'visible'; organizationName: string }; + +export function useOrganizationProfileSecurityGate(): OrganizationProfileSecurityGate { + const { isLoaded: isOrganizationLoaded, organization } = useOrganization(); + const { isLoaded: isSessionLoaded, session } = useSession(); + const environment = useMosaicEnvironment(); + + // A not-yet-known input must never collapse straight to 'hidden' (that flash-hides the tab). + if (!isOrganizationLoaded || !isSessionLoaded || !environment) { + return { status: 'loading' }; + } + + const canManage = session?.checkAuthorization({ permission: 'org:sys_entconns:manage' }) ?? false; + const featureEnabled = + environment.userSettings.enterpriseSSO.self_serve_sso && Boolean(organization?.selfServeSSOEnabled); + + if (!organization || !canManage || !featureEnabled) { + return { status: 'hidden' }; + } + + return { status: 'visible', organizationName: organization.name }; +} + +type OrganizationProfileSecurityPanelController = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + /** `overview` shows the badge + actions; `wizard` shows the ConfigureSSO flow. */ + mode: 'overview' | 'wizard'; + /** + * Data still cold-loading. The view shows the overview spinner ONLY while + * `mode === 'overview'`; an open wizard stays mounted regardless so a late + * `isLoading` flip (e.g. test-runs cold-loading after a configure write) never + * tears it down — each step owns its own loading UI (legacy `OrganizationSecurityPage`). + */ + isLoading: boolean; + organizationName: string; + /** The derived lifecycle entity the overview view reads its badge/status/domains from. */ + connection: OrganizationEnterpriseConnection; + /** The raw resource, for the domain chips the overview lists. */ + enterpriseConnection: EnterpriseConnectionResource | undefined; + openWizard: (forceInitialStep?: boolean) => void; + exitWizard: () => void; + overview: OrganizationProfileSecurityOverviewFlow; + wizard: OrganizationProfileSecurityWizardFlow; + domainsStep: OrganizationProfileSecurityWizardDomainsStep; + }; + +/** + * The single Clerk-facing layer for the Mosaic Security panel. It consumes the umbrella + * `useOrganizationEnterpriseConnection` hook once, gates on the enterprise-SSO feature flag + * and the `org:sys_entconns:manage` permission (waiting for full readiness before deciding + * `hidden`, so the tab never flash-hides), owns the overview + wizard machines, and injects + * the hook's mutations as plain id-bound functions so the machines stay Clerk-free. + */ +export function useOrganizationProfileSecurityPanelController(): OrganizationProfileSecurityPanelController { + const gate = useOrganizationProfileSecurityGate(); + const clerk = useClerk(); + const { + isLoading, + user, + organization, + enterpriseConnection, + organizationEnterpriseConnection, + enterpriseConnectionMutations, + organizationDomains, + organizationDomainMutations, + } = useOrganizationEnterpriseConnection(); + const { setConnectionActive, deleteConnection, updateConnection } = enterpriseConnectionMutations; + const { createDomain, prepareOwnershipVerification, revalidate } = organizationDomainMutations; + const connectionStatus = organizationEnterpriseConnection.status; + const connectionId = enterpriseConnection?.id ?? null; + const organizationId = organization?.id ?? null; + + // Controller-local, not a machine: a guardless, async-free toggle (per ADOPTION.md). + const [mode, setMode] = useState<'overview' | 'wizard'>('overview'); + + const organizationName = gate.status === 'visible' ? gate.organizationName : ''; + + const [overviewSnapshot, sendOverview, overviewActor] = useMachine(organizationProfileSecurityPanelOverviewMachine, { + context: { + organizationName, + activateConnection: async () => { + if (!enterpriseConnection) { + return; + } + try { + await setConnectionActive(enterpriseConnection.id, true); + } catch (err) { + throw toConnectionError(err); + } + }, + deactivateConnection: async () => { + if (!enterpriseConnection) { + return; + } + try { + await setConnectionActive(enterpriseConnection.id, false); + } catch (err) { + throw toConnectionError(err); + } + }, + removeConnection: async () => { + if (!enterpriseConnection) { + return; + } + try { + await deleteConnection(enterpriseConnection.id); + } catch (err) { + throw toConnectionError(err); + } + }, + }, + }); + + // Live reachability inputs the wizard's guards read from context. Reseated every + // render by useMachine; recheck() (below) re-settles the current step when they change. + const allDomainsVerified = areAllOrganizationDomainsVerified(organizationDomains); + const { hasConnection, hasMinimumConfiguration, isActive, hasSuccessfulTestRun } = organizationEnterpriseConnection; + + const [wizardSnapshot, sendWizard, wizardActor] = useMachine(organizationProfileSecurityWizardMachine, { + context: { allDomainsVerified, hasConnection, hasMinimumConfiguration, isActive, hasSuccessfulTestRun }, + }); + + // When the connection/domain data changes, re-settle the wizard: a step whose entry + // guard just broke re-seats to the furthest still-reachable step, and a parked NEXT + // completes once its target guard opens (replaces the legacy render-phase clamp). + useEffect(() => { + wizardActor.recheck(); + }, [wizardActor, allDomainsVerified, hasConnection, hasMinimumConfiguration, isActive, hasSuccessfulTestRun]); + + // The verify-domain step's three concurrent mutations, each its own machine (the + // domains-section pattern). The injected functions reproduce the legacy handlers 1:1 and + // normalize errors to plain messages so the machines stay Clerk-free. + const [domainsAddSnapshot, sendDomainsAdd] = useMachine(organizationProfileSecurityWizardDomainsAddMachine, { + context: { + createDomain: async name => { + try { + await createDomain(name); + } catch (err) { + throw toDomainError(err); + } + }, + }, + }); + + const [domainsPrepareSnapshot, sendDomainsPrepare] = useMachine( + organizationProfileSecurityWizardDomainsPrepareMachine, + { + context: { + prepareVerification: async domainId => { + const domain = organizationDomains?.find(candidate => candidate.id === domainId); + if (!domain) { + return; + } + try { + await prepareOwnershipVerification([domain]); + } catch (err) { + throw toDomainError(err); + } + }, + }, + }, + ); + + const [domainsRemoveSnapshot, sendDomainsRemove] = useMachine(organizationProfileSecurityWizardDomainsRemoveMachine, { + context: { + // Reproduces legacy `handleRemoveDomain`: drop the domain from the connection, delete the + // domain, then revalidate. Errors surface as the dialog's global error (legacy `handleError`). + removeDomain: async domainName => { + const domain = organizationDomains?.find(candidate => candidate.name === domainName); + try { + if (enterpriseConnection) { + const domains = enterpriseConnection.domains.filter(name => name !== domainName); + await updateConnection(enterpriseConnection.id, { domains }); + } + await domain?.delete(); + await revalidate(); + } catch (err) { + throw toConnectionError(err); + } + }, + }, + }); + + // Fired once when the verify-domain step mounts (legacy `eventFlowStepMounted`). + const onDomainsStepMounted = useCallback(() => { + clerk.telemetry?.record( + eventFlowStepMounted('configureSSO', 'verify-domain', { + timestamp: new Date().toISOString(), + connectionStatus, + connectionId, + organizationId, + }), + ); + }, [clerk, connectionStatus, connectionId, organizationId]); + + // The gate (above) already waited for full readiness before deciding; a non-visible + // gate maps straight through to the panel's loading/hidden status. + if (gate.status !== 'visible') { + return { status: gate.status }; + } + + return { + status: 'ready', + mode, + isLoading, + organizationName: gate.organizationName, + connection: organizationEnterpriseConnection, + enterpriseConnection, + // `forceInitialStep` seats the wizard at the first step (legacy Start / Edit); otherwise + // it resumes wherever the actor's furthest-reachable seat left it (legacy Continue). + openWizard: (forceInitialStep = false) => { + if (forceInitialStep) { + sendWizard({ type: 'GOTO', step: 'verify-domain' }); + } + setMode('wizard'); + }, + exitWizard: () => setMode('overview'), + overview: { + snapshot: overviewSnapshot, + send: sendOverview, + canConfirmRemove: overviewActor.can({ type: 'CONFIRM_REMOVE' }), + }, + wizard: { + snapshot: wizardSnapshot, + send: sendWizard, + }, + domainsStep: { + domains: organizationDomains, + suggestedDomain: emailDomain(user?.primaryEmailAddress?.emailAddress), + hasConnection, + isConnectionActive: isActive, + add: { snapshot: domainsAddSnapshot, send: sendDomainsAdd }, + prepare: { snapshot: domainsPrepareSnapshot, send: sendDomainsPrepare }, + remove: { snapshot: domainsRemoveSnapshot, send: sendDomainsRemove }, + onStepMounted: onDomainsStepMounted, + }, + }; +} diff --git a/packages/ui/src/mosaic/organization/organization-profile-security-panel.tsx b/packages/ui/src/mosaic/organization/organization-profile-security-panel.tsx new file mode 100644 index 00000000000..cb62cbc95d1 --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-security-panel.tsx @@ -0,0 +1,31 @@ +import type { ReactElement } from 'react'; + +import { useOrganizationProfileSecurityPanelController } from './organization-profile-security-panel.controller'; +import { OrganizationProfileSecurityPanelView } from './organization-profile-security-panel.view'; + +/** + * The Mosaic Enterprise SSO Security panel. Self-gating: renders nothing until the + * controller reports `ready` (org/session/environment resolved and the feature flag + + * `org:sys_entconns:manage` permission gate passes). The shell decides whether the + * Security *tab* renders at all, but the panel stays self-gating as defense in depth. + */ +export function OrganizationProfileSecurityPanel(): ReactElement | null { + const controller = useOrganizationProfileSecurityPanelController(); + if (controller.status !== 'ready') { + return null; + } + return ( + + ); +} diff --git a/packages/ui/src/mosaic/organization/organization-profile-security-panel.view.tsx b/packages/ui/src/mosaic/organization/organization-profile-security-panel.view.tsx new file mode 100644 index 00000000000..a711c59300d --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-security-panel.view.tsx @@ -0,0 +1,461 @@ +import type { EnterpriseConnectionResource } from '@clerk/shared/types'; + +import type { + OrganizationEnterpriseConnection, + OrganizationEnterpriseConnectionStatus, +} from '@/components/ConfigureSSO/domain/organizationEnterpriseConnection'; + +import { Destructive } from '../block/destructive'; +import { Box } from '../components/box'; +import { Button } from '../components/button'; +import { Heading } from '../components/heading'; +import { Skeleton } from '../components/skeleton'; +import { Text } from '../components/text'; +import type { Snapshot } from '../machine/types'; +import type { OrganizationProfileSecurityWizardDomainsStep } from './organization-profile-security-panel.controller'; +import type { + OrganizationProfileSecurityPanelOverviewContext, + OrganizationProfileSecurityPanelOverviewEvent, +} from './organization-profile-security-panel-overview.machine'; +import type { + OrganizationProfileSecurityWizardContext, + OrganizationProfileSecurityWizardEvent, + SecurityWizardStepId, +} from './organization-profile-security-wizard.machine'; +import { + isSecurityWizardStepComplete, + isSecurityWizardStepReachable, + SECURITY_WIZARD_STEP_LABELS, + SECURITY_WIZARD_STEP_ORDER, +} from './organization-profile-security-wizard.machine'; +import { OrganizationProfileSecurityWizardDomainsView } from './organization-profile-security-wizard-domains.view'; + +/** + * The Mosaic Security panel view — pure rendering over the controller's snapshots. + * + * It renders the SSO overview (`mode === 'overview'`) or the ConfigureSSO wizard shell + * (`mode === 'wizard'`). Every string here matches the legacy `securityPage.*` + * localization values 1:1 (`SecuritySsoSection.tsx`); the Mosaic panels render plain + * English rather than `localizationKeys`, matching the other migrated sections. + * + * The legacy three-dot menu (Edit / Activate|Deactivate / Remove) is flattened into + * inline action buttons — the same actions and events, matching the domains-section + * migration. The enrollment-role tooltip is not rendered yet: the controller does not + * expose the derived role label (a tracked Phase-1 item), so it is deferred rather than + * approximated. + */ + +interface OverviewFlow { + snapshot: Snapshot; + send: (event: OrganizationProfileSecurityPanelOverviewEvent) => void; + /** Whether the typed confirmation currently matches the org name (the remove guard). */ + canConfirmRemove: boolean; +} + +interface WizardFlow { + snapshot: Snapshot; + send: (event: OrganizationProfileSecurityWizardEvent) => void; +} + +export interface OrganizationProfileSecurityPanelViewProps { + mode: 'overview' | 'wizard'; + isLoading: boolean; + organizationName: string; + connection: OrganizationEnterpriseConnection; + /** Narrowed to what the view reads (the domain chips); the controller passes the full resource. */ + enterpriseConnection: Pick | undefined; + openWizard: (forceInitialStep?: boolean) => void; + exitWizard: () => void; + overview: OverviewFlow; + wizard: WizardFlow; + domainsStep: OrganizationProfileSecurityWizardDomainsStep; +} + +const STATUS_LABEL: Record = { + unconfigured: 'Unconfigured', + in_progress: 'In Progress', + active: 'Active', + inactive: 'Inactive', +}; + +/** The connection description; legacy `ssoSection.descriptionLine1`. */ +const SSO_DESCRIPTION = 'Require members with a matching email domain to sign in through your identity provider.'; + +type BadgeTone = 'neutral' | 'emphasis' | 'destructive'; + +/** + * The status badge tone. The Mosaic palette has no success/warning tokens, so the + * legacy success/warning/danger color schemes collapse onto the available tokens; the + * badge *label* (which the functional spec asserts) still matches legacy exactly. + */ +const STATUS_TONE: Record = { + unconfigured: 'neutral', + in_progress: 'neutral', + active: 'emphasis', + inactive: 'destructive', +}; + +function Badge({ children, tone = 'neutral' }: { children: React.ReactNode; tone?: BadgeTone }) { + return ( + } + sx={t => ({ + display: 'inline-flex', + alignItems: 'center', + ...t.text('xs'), + fontWeight: t.font.medium, + paddingInline: t.spacing(1.5), + paddingBlock: t.spacing(0.5), + borderRadius: t.rounded.full, + color: + tone === 'destructive' + ? t.color.destructive + : tone === 'emphasis' + ? t.color.primary + : t.color.mutedForeground, + backgroundColor: + tone === 'destructive' + ? t.alpha('destructive', 12) + : tone === 'emphasis' + ? t.alpha('primary', 12) + : t.alpha('primary', 8), + })} + > + {children} + + ); +} + +function SectionHeader({ status }: { status: OrganizationEnterpriseConnectionStatus }) { + return ( + ({ display: 'flex', alignItems: 'center', gap: t.spacing(2) })}> + SSO + {STATUS_LABEL[status]} + + ); +} + +function Description() { + return ( +

} + intent='mutedForeground' + sx={t => ({ textWrap: 'balance', marginBlockStart: t.spacing(1) })} + > + {SSO_DESCRIPTION} + + ); +} + +function DomainChips({ domains }: { domains: string[] }) { + if (domains.length === 0) { + return null; + } + return ( + ({ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: t.spacing(1.5) })}> + } + intent='mutedForeground' + > + Domains: + + {domains.map(domain => ( + {domain} + ))} + + ); +} + +function OverviewContent({ + organizationName, + connection, + enterpriseConnection, + openWizard, + overview, +}: Pick< + OrganizationProfileSecurityPanelViewProps, + 'organizationName' | 'connection' | 'enterpriseConnection' | 'openWizard' | 'overview' +>) { + const { snapshot, send, canConfirmRemove } = overview; + const status = connection.status; + const isConfigured = status === 'active' || status === 'inactive'; + const isActive = status === 'active'; + const isMutating = snapshot.value === 'activating' || snapshot.value === 'deactivating'; + const isRemoving = snapshot.value === 'removing'; + const isRemoveOpen = snapshot.value === 'confirmingRemove' || isRemoving; + const domains = enterpriseConnection?.domains ?? []; + // The remove dialog owns the error while it is open; otherwise a mutation error + // (activate/deactivate) shows in the section-level alert (legacy `Card.Alert`). + const sectionError = !isRemoveOpen ? snapshot.context.error : null; + + return ( + ({ display: 'flex', flexDirection: 'column', gap: t.spacing(4) })}> + + + + {status === 'unconfigured' && ( + + )} + + {status === 'in_progress' && ( + + )} + + {isConfigured && ( + <> + ({ display: 'flex', alignItems: 'center', gap: t.spacing(2) })}> + + + ( + + )} + open={isRemoveOpen} + onOpenChange={isOpen => send({ type: isOpen ? 'OPEN_REMOVE' : 'CANCEL_REMOVE' })} + title='Remove SSO connection' + description='Are you sure you want to remove the connection? This action is irreversible and deletes the connection and all of its configuration.' + resourceName={organizationName} + confirmationValue={snapshot.context.confirmationValue} + onConfirmationValueChange={value => send({ type: 'TYPE_CONFIRMATION', value })} + primaryActionLabel='Remove connection' + onDelete={() => send({ type: 'CONFIRM_REMOVE' })} + isDeleting={isRemoving} + canSubmit={canConfirmRemove} + error={snapshot.context.error} + /> + + + {sectionError && ( + ( +

+ )} + intent='destructive' + > + {sectionError} + + )} + + + + )} + + ); +} + +/** + * The ConfigureSSO wizard shell: a breadcrumb of the four steps (from the machine's step + * order/labels, gated by live reachability + completion), the current step body, and the + * NEXT/PREV nav. The step bodies are placeholders for now — Phase 3 swaps in the per-step + * views. Navigation is functional: the breadcrumb sends GOTO (reachable steps only), and + * Back/Continue send PREV/NEXT bounded by the first/last positions. + * + * The controller owns the machine (furthest-reachable seat, recheck, deferred advance); + * this layer only reads the snapshot and sends events. + */ +function WizardBreadcrumb({ + current, + context, + send, +}: { + current: SecurityWizardStepId; + context: OrganizationProfileSecurityWizardContext; + send: WizardFlow['send']; +}) { + return ( +