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/swingset/next.config.mjs b/packages/swingset/next.config.mjs index b928fd48cf4..1c97dc37498 100644 --- a/packages/swingset/next.config.mjs +++ b/packages/swingset/next.config.mjs @@ -59,6 +59,10 @@ const nextConfig = { config.module.rules.push({ resourceQuery: /raw/, type: 'asset/source' }); config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic'); + // The Mosaic Security panel reuses the ConfigureSSO domain/hooks/types from `@clerk/ui` + // source via its internal `@/` alias, so resolve that one subtree to the ui source too + // (distinct from swingset's own `@/components/ui/*`, so no collision). + config.resolve.alias['@/components/ConfigureSSO'] = resolve(__dirname, '../ui/src/components/ConfigureSSO'); // Consume @clerk/headless primitives from source (no dist build needed), mirroring Mosaic. // `/hooks` and `/utils` live outside `primitives/`, so alias them first (more specific wins). config.resolve.alias['@clerk/headless/hooks'] = resolve(__dirname, '../headless/src/hooks'); diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 35bdb3e9da1..0cc75cef390 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -15,6 +15,7 @@ const docModules: Record> = { 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), 'organization-profile-api-keys-panel': dynamic(() => import('../stories/organization-profile-api-keys-panel.mdx')), 'organization-profile-members-panel': dynamic(() => import('../stories/organization-profile-members-panel.mdx')), + 'organization-profile-security-panel': dynamic(() => import('../stories/organization-profile-security-panel.mdx')), 'organization-profile-profile-section': dynamic( () => import('../stories/organization-profile-profile-section.mdx'), ), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dee445b26f0..64209630eee 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -66,6 +66,10 @@ import { Default as OrganizationProfileProfileSectionDefault, meta as organizationProfileProfileSectionMeta, } from '../stories/organization-profile-profile-section.stories'; +import { + Default as OrganizationProfileSecurityPanelDefault, + meta as organizationProfileSecurityPanelMeta, +} from '../stories/organization-profile-security-panel.stories'; import { meta as otpMeta } from '../stories/otp.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; import { meta as selectMeta } from '../stories/select.stories'; @@ -112,6 +116,10 @@ const organizationProfileMembersPanelModule: StoryModule = { meta: organizationProfileMembersPanelMeta, Default: OrganizationProfileMembersPanelDefault, }; +const organizationProfileSecurityPanelModule: StoryModule = { + meta: organizationProfileSecurityPanelMeta, + Default: OrganizationProfileSecurityPanelDefault, +}; const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered }; @@ -164,6 +172,7 @@ export const registry: StoryModule[] = [ organizationProfileGeneralPanelModule, organizationProfileApiKeysPanelModule, organizationProfileMembersPanelModule, + organizationProfileSecurityPanelModule, organizationProfileProfileSectionModule, organizationProfileDomainsSectionModule, organizationProfileLeaveSectionModule, diff --git a/packages/swingset/src/stories/organization-profile-security-panel.mdx b/packages/swingset/src/stories/organization-profile-security-panel.mdx new file mode 100644 index 00000000000..18a3a825aa3 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-security-panel.mdx @@ -0,0 +1,17 @@ +import * as OrganizationProfileSecurityPanelStories from './organization-profile-security-panel.stories'; + +# Organization Profile Security Panel + +The Security tab panel of the Organization Profile — the Enterprise SSO overview (status badge, activate / deactivate / remove, domain chips) and the full ConfigureSSO wizard (`verify-domain → configure → test → activate`). It gates on the enterprise-SSO feature flag plus `org:sys_entconns:manage`, so the tab renders only for admins on an org with self-serve SSO enabled. Click **Edit** to open the wizard. + + diff --git a/packages/swingset/src/stories/organization-profile-security-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-security-panel.stories.tsx new file mode 100644 index 00000000000..cf67ec3a350 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-security-panel.stories.tsx @@ -0,0 +1,210 @@ +/** @jsxImportSource @emotion/react */ +import type { OrganizationDomainResource } from '@clerk/shared/types'; +import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; +import type { OrganizationProfileSecurityPanelViewProps } from '@clerk/ui/mosaic/organization/organization-profile-security-panel.view'; +import { OrganizationProfileSecurityPanelView } from '@clerk/ui/mosaic/organization/organization-profile-security-panel.view'; +import { organizationProfileSecurityPanelOverviewMachine } from '@clerk/ui/mosaic/organization/organization-profile-security-panel-overview.machine'; +import { organizationProfileSecurityWizardMachine } from '@clerk/ui/mosaic/organization/organization-profile-security-wizard.machine'; +import { organizationProfileSecurityWizardActivateMachine } from '@clerk/ui/mosaic/organization/organization-profile-security-wizard-activate.machine'; +import { + CONFIGURE_PROVIDER_STEPS, + organizationProfileSecurityWizardConfigureMachine, + SAML_SUBMIT_STEP_ID, +} from '@clerk/ui/mosaic/organization/organization-profile-security-wizard-configure.machine'; +import { organizationProfileSecurityWizardDomainsAddMachine } from '@clerk/ui/mosaic/organization/organization-profile-security-wizard-domains-add.machine'; +import { organizationProfileSecurityWizardDomainsPrepareMachine } from '@clerk/ui/mosaic/organization/organization-profile-security-wizard-domains-prepare.machine'; +import { organizationProfileSecurityWizardDomainsRemoveMachine } from '@clerk/ui/mosaic/organization/organization-profile-security-wizard-domains-remove.machine'; +import { organizationProfileSecurityWizardTestMachine } from '@clerk/ui/mosaic/organization/organization-profile-security-wizard-test.machine'; +import { useEffect, useRef, useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Organization', + title: 'OrganizationProfileSecurityPanel', + source: 'packages/ui/src/mosaic/organization/organization-profile-security-panel.tsx', +}; + +// Demo async deps: resolve after a short delay so the in-flight (busy) states are visible. +const delay = () => new Promise(resolve => setTimeout(resolve, 600)); + +// An active connection so the overview shows the full state (Active badge, domain chips, the +// Edit / Deactivate / Remove actions). Click Edit to open the ConfigureSSO wizard. +const DEMO_PROVIDER = 'saml_okta' as const; +const DEMO_CONNECTION: OrganizationProfileSecurityPanelViewProps['connection'] = { + provider: DEMO_PROVIDER, + hasConnection: true, + isActive: true, + hasMinimumConfiguration: true, + hasSuccessfulTestRun: true, + status: 'active', +}; + +// The view reads only id/name/verification for the chips, so a partial fixture is enough. The +// cast is confined to this swingset demo fixture (matching the domains-section story). +const DEMO_DOMAINS = [ + { id: 'dmn_1', name: 'acme.com', verification: { status: 'verified' } }, + { id: 'dmn_2', name: 'acme.dev', verification: { status: 'verified' } }, +] as unknown as OrganizationDomainResource[]; + +export function Default() { + // Controller-local in the real panel; here a plain toggle the overview/wizard actions drive. + const [mode, setMode] = useState<'overview' | 'wizard'>('overview'); + + const [overviewSnapshot, sendOverview, overviewActor] = useMachine(organizationProfileSecurityPanelOverviewMachine, { + context: { + organizationName: 'Acme Inc', + activateConnection: delay, + deactivateConnection: delay, + removeConnection: delay, + }, + }); + + const [wizardSnapshot, sendWizard] = useMachine(organizationProfileSecurityWizardMachine, { + context: { + allDomainsVerified: true, + hasConnection: true, + hasMinimumConfiguration: true, + isActive: true, + hasSuccessfulTestRun: true, + }, + }); + + const [domainsAddSnapshot, sendDomainsAdd] = useMachine(organizationProfileSecurityWizardDomainsAddMachine, { + context: { createDomain: delay }, + }); + const [domainsPrepareSnapshot, sendDomainsPrepare] = useMachine( + organizationProfileSecurityWizardDomainsPrepareMachine, + { context: { prepareVerification: delay } }, + ); + const [domainsRemoveSnapshot, sendDomainsRemove] = useMachine(organizationProfileSecurityWizardDomainsRemoveMachine, { + context: { removeDomain: delay }, + }); + + const providerSteps = CONFIGURE_PROVIDER_STEPS[DEMO_PROVIDER]; + const [configureSnapshot, sendConfigure] = useMachine(organizationProfileSecurityWizardConfigureMachine, { + context: { + providerSteps, + submitIndex: providerSteps.indexOf(SAML_SUBMIT_STEP_ID), + hasConnection: true, + createConnection: delay, + changeProvider: delay, + updateConnection: delay, + }, + }); + + const [testSnapshot, sendTest] = useMachine(organizationProfileSecurityWizardTestMachine, { + context: { + hasSuccessfulTestRun: true, + noSuccessfulRunMessage: 'Run a test and complete it successfully before continuing.', + createTestRun: delay, + revalidateHasSuccessfulTestRun: async () => { + await delay(); + return true; + }, + }, + }); + + const [activateSnapshot, sendActivate] = useMachine(organizationProfileSecurityWizardActivateMachine, { + context: { isActive: true, activateConnection: delay }, + }); + + // The controller forwards a configure/test terminal-save bubble to the outer wizard on the rising + // edge into `bubblingNext`; mirror that here so the wizard advances in the demo. One-shot via ref. + const configureBubbled = useRef(false); + useEffect(() => { + if (configureSnapshot.value === 'bubblingNext') { + if (!configureBubbled.current) { + configureBubbled.current = true; + sendWizard({ type: 'NEXT' }); + } + } else { + configureBubbled.current = false; + } + }, [configureSnapshot.value, sendWizard]); + + const testBubbled = useRef(false); + useEffect(() => { + if (testSnapshot.value === 'bubblingNext') { + if (!testBubbled.current) { + testBubbled.current = true; + sendWizard({ type: 'NEXT' }); + } + } else { + testBubbled.current = false; + } + }, [testSnapshot.value, sendWizard]); + + // A completed activate returns the panel to the overview (controller's `activated` → overview). + useEffect(() => { + if (activateSnapshot.value === 'activated') { + setMode('overview'); + } + }, [activateSnapshot.value]); + + return ( + { + 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: DEMO_DOMAINS, + suggestedDomain: 'acme.io', + hasConnection: true, + isConnectionActive: true, + add: { snapshot: domainsAddSnapshot, send: sendDomainsAdd }, + prepare: { snapshot: domainsPrepareSnapshot, send: sendDomainsPrepare }, + remove: { snapshot: domainsRemoveSnapshot, send: sendDomainsRemove }, + onStepMounted: () => {}, + }} + configureStep={{ + snapshot: configureSnapshot, + send: sendConfigure, + provider: DEMO_PROVIDER, + providerSteps, + submitStepId: SAML_SUBMIT_STEP_ID, + enteredForward: wizardSnapshot.context.direction === 1, + onParentNext: () => sendWizard({ type: 'NEXT' }), + onParentPrev: () => sendWizard({ type: 'PREV' }), + }} + testStep={{ + snapshot: testSnapshot, + send: sendTest, + testRuns: { + rows: [], + totalCount: 0, + isLoading: false, + isFetching: false, + isPolling: false, + page: 1, + setPage: () => {}, + refresh: async () => {}, + revalidateHasSuccessfulTestRun: async () => true, + }, + onParentPrev: () => sendWizard({ type: 'PREV' }), + }} + activateStep={{ + snapshot: activateSnapshot, + send: sendActivate, + isActive: true, + domain: 'acme.com, acme.dev', + onExit: () => setMode('overview'), + }} + /> + ); +} diff --git a/packages/swingset/src/stories/organization-profile.stories.tsx b/packages/swingset/src/stories/organization-profile.stories.tsx index 0d389ec1ab2..b2e5fe2d820 100644 --- a/packages/swingset/src/stories/organization-profile.stories.tsx +++ b/packages/swingset/src/stories/organization-profile.stories.tsx @@ -6,6 +6,7 @@ import type { StoryMeta } from '@/lib/types'; import { Default as OrganizationProfileApiKeysPanelDemo } from './organization-profile-api-keys-panel.stories'; import { Default as OrganizationProfileGeneralPanelDemo } from './organization-profile-general-panel.stories'; import { Default as OrganizationProfileMembersPanelDemo } from './organization-profile-members-panel.stories'; +import { Default as OrganizationProfileSecurityPanelDemo } from './organization-profile-security-panel.stories'; export const meta: StoryMeta = { group: 'Organization', @@ -19,6 +20,7 @@ export function Default() { general={} members={} apiKeys={} + security={} /> ); } diff --git a/packages/swingset/tsconfig.json b/packages/swingset/tsconfig.json index 10b28314518..a49a7827043 100644 --- a/packages/swingset/tsconfig.json +++ b/packages/swingset/tsconfig.json @@ -10,6 +10,7 @@ "moduleDetection": "force", "lib": ["ES2023", "DOM"], "paths": { + "@/components/ConfigureSSO/*": ["../ui/src/components/ConfigureSSO/*"], "@/*": ["./src/*"], "@clerk/ui/mosaic": ["../ui/src/mosaic"], "@clerk/ui/mosaic/*": ["../ui/src/mosaic/*"], 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..c680bcd6c91 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel.controller.test.tsx @@ -0,0 +1,488 @@ +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<{ id?: string; name?: string; delete?: () => Promise; ownershipVerification?: { status: string } }> + | undefined; + +let setConnectionActive: ReturnType; +let deleteConnection: ReturnType; +let updateConnection: ReturnType; +let createTestRunMutation: ReturnType; +let refreshTestRuns: ReturnType; +let setTestRunsPage: ReturnType; +let revalidateHasSuccessfulTestRun: ReturnType; +let revalidateDomains: ReturnType; +let deleteDomain: ReturnType; +/** Records the order of the remove-domain side effects so the test can assert the sequence. */ +let removeDomainCalls: string[]; +let provider: 'saml_okta' | undefined; + +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, provider: provider ?? connectionEntity.provider }, + organizationDomains, + enterpriseConnectionMutations: { + setConnectionActive, + deleteConnection, + updateConnection, + createConnection: vi.fn().mockResolvedValue(undefined), + changeProvider: vi.fn().mockResolvedValue(undefined), + createTestRun: createTestRunMutation, + }, + testRuns: { + rows: [], + totalCount: 0, + isLoading: false, + isFetching: false, + isPolling: false, + page: 1, + setPage: setTestRunsPage, + refresh: refreshTestRuns, + revalidateHasSuccessfulTestRun, + }, + organizationDomainMutations: { + createDomain: vi.fn().mockResolvedValue(undefined), + prepareOwnershipVerification: vi.fn().mockResolvedValue(undefined), + revalidate: revalidateDomains, + }, + }), +})); + +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' } }]; + provider = undefined; + setConnectionActive = vi.fn().mockResolvedValue(undefined); + deleteConnection = vi.fn().mockResolvedValue(undefined); + updateConnection = vi.fn().mockResolvedValue(undefined); + createTestRunMutation = vi.fn().mockResolvedValue({ url: 'https://example.com/test' }); + refreshTestRuns = vi.fn().mockResolvedValue(undefined); + setTestRunsPage = vi.fn(); + revalidateHasSuccessfulTestRun = vi.fn().mockResolvedValue(false); + revalidateDomains = vi.fn().mockResolvedValue(undefined); + removeDomainCalls = []; + deleteDomain = vi.fn(() => { + removeDomainCalls.push('delete'); + return Promise.resolve(); + }); +}); + +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} + + + + + + + + + + + {controller.configureStep.snapshot.value} + {controller.testStep.snapshot.value} + {controller.activateStep.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'); + }); +}); + +describe('useOrganizationProfileSecurityPanelController — test-step polling arm distinction', () => { + it('arms polling when a run is created: createTestRun(id) → setPage(1) → refresh({armPolling:true}) → open the URL', async () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null); + render(); + + await act(async () => { + fireEvent.click(screen.getByText('test create run')); + }); + + expect(createTestRunMutation).toHaveBeenCalledWith('ent_1'); + expect(setTestRunsPage).toHaveBeenCalledWith(1); + expect(refreshTestRuns).toHaveBeenCalledWith({ armPolling: true }); + expect(open).toHaveBeenCalledWith('https://example.com/test', '_blank', 'noopener,noreferrer'); + + open.mockRestore(); + }); + + it('does NOT arm polling on the manual "Refresh logs" refetch (a bare refresh() the view threads through)', () => { + render(); + + fireEvent.click(screen.getByText('test manual refresh')); + + expect(refreshTestRuns).toHaveBeenCalledTimes(1); + // No `{ armPolling: true }` — the manual refetch is the raw hook refresh, not the create-run arm. + expect(refreshTestRuns).toHaveBeenCalledWith(); + }); +}); + +describe('useOrganizationProfileSecurityPanelController — terminal bubble seams', () => { + it('forwards the configure terminal-save bubble to the outer wizard, landing on test once the config revalidates', async () => { + provider = 'saml_okta'; + // Domains verified, connection present but not yet minimally configured → wizard at configure. + connectionEntity = buildEntity({ hasConnection: true, status: 'in_progress' }); + const { rerender } = render(); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('configure'); + expect(screen.getByTestId('configure-state')).toHaveTextContent('configuring'); + + // Walk the inner SAML steps to the terminal metadata step (Okta submit index 3) and save. + fireEvent.click(screen.getByText('configure next')); + fireEvent.click(screen.getByText('configure next')); + fireEvent.click(screen.getByText('configure next')); + await act(async () => { + fireEvent.click(screen.getByText('configure save')); + }); + + // The terminal save bubbles; the controller forwards NEXT but the outer wizard parks it on the + // still-stale test guard (hasMinimumConfiguration not yet revalidated). + expect(screen.getByTestId('configure-state')).toHaveTextContent('bubblingNext'); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('configure'); + + // The updateConnection revalidate lands: config is now minimal → recheck completes the parked advance. + connectionEntity = buildEntity({ hasConnection: true, hasMinimumConfiguration: true, status: 'in_progress' }); + rerender(); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('test'); + }); + + it('forwards the test-continue bubble to the outer wizard, landing on activate once the success probe revalidates', async () => { + connectionEntity = buildEntity({ hasConnection: true, hasMinimumConfiguration: true, status: 'in_progress' }); + revalidateHasSuccessfulTestRun.mockResolvedValue(true); + const { rerender } = render(); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('test'); + + await act(async () => { + fireEvent.click(screen.getByText('test continue')); + }); + + // Continue found a fresh successful run → bubbles; the outer wizard parks NEXT on the stale + // activate guard (hasSuccessfulTestRun not yet reseated into wizard context). + expect(screen.getByTestId('test-state')).toHaveTextContent('bubblingNext'); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('test'); + + connectionEntity = buildEntity({ + hasConnection: true, + hasMinimumConfiguration: true, + hasSuccessfulTestRun: true, + status: 'in_progress', + }); + rerender(); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('activate'); + }); + + it('returns to the overview after the activate step completes (activate onDone → activated → overview)', async () => { + connectionEntity = buildEntity({ + hasConnection: true, + hasMinimumConfiguration: true, + hasSuccessfulTestRun: true, + status: 'in_progress', + }); + render(); + expect(screen.getByTestId('wizard-state')).toHaveTextContent('activate'); + + fireEvent.click(screen.getByText('Open wizard')); + expect(screen.getByTestId('mode')).toHaveTextContent('wizard'); + + await act(async () => { + fireEvent.click(screen.getByText('activate step')); + }); + + expect(setConnectionActive).toHaveBeenCalledWith('ent_1', true); + expect(screen.getByTestId('activate-state')).toHaveTextContent('activated'); + expect(screen.getByTestId('mode')).toHaveTextContent('overview'); + }); +}); + +describe('useOrganizationProfileSecurityPanelController — remove-domain side-effect order', () => { + it('removes a domain in the legacy order: drop from the connection → delete the domain → revalidate', async () => { + updateConnection.mockImplementation(() => { + removeDomainCalls.push('update'); + return Promise.resolve(); + }); + revalidateDomains.mockImplementation(() => { + removeDomainCalls.push('revalidate'); + return Promise.resolve(); + }); + organizationDomains = [ + { id: 'dom_1', name: 'acme.com', delete: deleteDomain, ownershipVerification: { status: 'verified' } }, + ]; + enterpriseConnection = { id: 'ent_1', domains: ['acme.com', 'other.com'] }; + render(); + + fireEvent.click(screen.getByText('remove open')); + await act(async () => { + fireEvent.click(screen.getByText('remove confirm')); + }); + + // The three side effects must fire in this exact sequence (legacy `handleRemoveDomain`). + expect(removeDomainCalls).toEqual(['update', 'delete', 'revalidate']); + // The connection is updated with the removed domain dropped, keeping the rest. + expect(updateConnection).toHaveBeenCalledWith('ent_1', { domains: ['other.com'] }); + }); +}); 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..172b41c4497 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-panel.view.test.tsx @@ -0,0 +1,414 @@ +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 { OrganizationProfileSecurityPanelViewProps } from '../organization-profile-security-panel.view'; +import { OrganizationProfileSecurityPanelView } from '../organization-profile-security-panel.view'; +import type { OrganizationProfileSecurityPanelOverviewContext } from '../organization-profile-security-panel-overview.machine'; +import type { OrganizationProfileSecurityWizardContext } from '../organization-profile-security-wizard.machine'; + +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(), +}); + +// A configure step seated at select-provider with no connection — the panel-view shell tests only +// need a valid (idle) flow so the step body renders without crashing. +const buildConfigureStep = (): OrganizationProfileSecurityPanelViewProps['configureStep'] => ({ + snapshot: { + value: 'selecting', + status: 'active', + context: { + providerSteps: [], + submitIndex: -1, + stepIndex: 0, + direction: 0, + hasConnection: false, + error: null, + pendingEnter: false, + pendingProvider: null, + pendingPayload: {}, + createConnection: async () => {}, + changeProvider: async () => {}, + updateConnection: async () => {}, + }, + }, + send: vi.fn(), + provider: undefined, + providerSteps: [], + submitStepId: 'identity-provider-metadata', + enteredForward: false, + onParentNext: vi.fn(), + onParentPrev: vi.fn(), +}); + +// A test step seated at idle with an empty run list — the panel-view shell tests never seat the +// wizard on `test`, so this just needs a valid (idle) flow to satisfy the prop type. +const buildTestStep = (): OrganizationProfileSecurityPanelViewProps['testStep'] => ({ + snapshot: { + value: 'idle', + status: 'active', + context: { + hasSuccessfulTestRun: false, + error: null, + noSuccessfulRunMessage: '', + createTestRun: async () => {}, + revalidateHasSuccessfulTestRun: async () => false, + }, + }, + send: vi.fn(), + testRuns: { + rows: [], + totalCount: 0, + isLoading: false, + isFetching: false, + isPolling: false, + page: 1, + setPage: vi.fn(), + refresh: vi.fn(() => Promise.resolve()), + revalidateHasSuccessfulTestRun: vi.fn(() => Promise.resolve(false)), + }, + onParentPrev: vi.fn(), +}); + +// An activate step seated at idle — the panel-view shell tests never seat the wizard on `activate` +// (except the loading test below), so this just needs a valid (idle) flow. +const buildActivateStep = (): OrganizationProfileSecurityPanelViewProps['activateStep'] => ({ + snapshot: { + value: 'idle', + status: 'active', + context: { isActive: false, error: null, activateConnection: async () => {} }, + }, + send: vi.fn(), + isActive: false, + domain: 'acme.com', + onExit: 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(), + configureStep: buildConfigureStep(), + testStep: buildTestStep(), + activateStep: buildActivateStep(), + ...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', () => { + // Every step now renders its own view; seat `activate` and assert its body renders under loading. + renderView({ mode: 'wizard', isLoading: true, wizard: { snapshot: wizardSnapshot('activate'), send: vi.fn() } }); + expect(screen.getByText('SSO connection configured')).toBeInTheDocument(); + }); + + 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 from the shell nav on a step that does not own its own nav (verify-domain)', () => { + const send = vi.fn(); + // configure + test own their nav; verify-domain (first) still uses the shell's generic Continue. + renderView({ mode: 'wizard', wizard: { snapshot: wizardSnapshot('verify-domain'), send } }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(send).toHaveBeenCalledWith({ type: 'NEXT' }); + }); + + it('suppresses the shell nav on a step that owns its own nav (activate)', () => { + renderView({ + mode: 'wizard', + wizard: { + snapshot: wizardSnapshot('activate', { + allDomainsVerified: true, + hasConnection: true, + hasMinimumConfiguration: true, + hasSuccessfulTestRun: true, + }), + send: vi.fn(), + }, + }); + // The step renders its own actions; the shell's generic Back/Continue is not present. + expect(screen.getByRole('button', { name: 'Activate SSO' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Continue' })).not.toBeInTheDocument(); + }); + + 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-activate.machine.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-activate.machine.test.ts new file mode 100644 index 00000000000..e7d1e9c6457 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-activate.machine.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { tick } from '../../machines/__tests__/test-utils'; +import type { OrganizationProfileSecurityWizardActivateContext } from '../organization-profile-security-wizard-activate.machine'; +import { organizationProfileSecurityWizardActivateMachine } from '../organization-profile-security-wizard-activate.machine'; + +function start(context: Partial = {}) { + const activateConnection = vi.fn(() => Promise.resolve()); + const actor = createActor(organizationProfileSecurityWizardActivateMachine, { + context: { activateConnection, ...context }, + }); + actor.start(); + return { actor, activateConnection }; +} + +describe('organizationProfileSecurityWizardActivateMachine', () => { + it('seeds at idle', () => { + const { actor } = start(); + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('activates the connection and lands on activated', async () => { + const { actor, activateConnection } = start({ isActive: false }); + + actor.send({ type: 'ACTIVATE' }); + expect(actor.getSnapshot().value).toBe('activating'); + expect(activateConnection).toHaveBeenCalledTimes(1); + + await tick(); + expect(actor.getSnapshot().value).toBe('activated'); + }); + + it('surfaces the error and returns to idle when activation fails', async () => { + const activateConnection = vi.fn(() => Promise.reject(new Error('Activation failed'))); + const { actor } = start({ isActive: false, activateConnection }); + + actor.send({ type: 'ACTIVATE' }); + await tick(); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.error).toBe('Activation failed'); + }); + + it('resets back to idle and clears the error on ENTER', async () => { + const activateConnection = vi.fn(() => Promise.reject(new Error('Activation failed'))); + const { actor } = start({ isActive: false, activateConnection }); + actor.send({ type: 'ACTIVATE' }); + await tick(); + expect(actor.getSnapshot().context.error).toBe('Activation failed'); + + actor.send({ type: 'ENTER' }); + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.error).toBeNull(); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-activate.view.test.tsx b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-activate.view.test.tsx new file mode 100644 index 00000000000..e24ad3fab8f --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-activate.view.test.tsx @@ -0,0 +1,78 @@ +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 { OrganizationProfileSecurityWizardActivateStep } from '../organization-profile-security-panel.controller'; +import type { OrganizationProfileSecurityWizardActivateContext } from '../organization-profile-security-wizard-activate.machine'; +import { OrganizationProfileSecurityWizardActivateView } from '../organization-profile-security-wizard-activate.view'; + +const snapshot = ( + value: string, + context: Partial = {}, +): Snapshot => ({ + value, + status: 'active', + context: { + isActive: false, + error: null, + activateConnection: async () => {}, + ...context, + }, +}); + +function renderView(overrides: Partial = {}) { + const send = vi.fn(); + const onExit = vi.fn(); + + const activate: OrganizationProfileSecurityWizardActivateStep = { + snapshot: snapshot('idle'), + send, + isActive: false, + domain: 'acme.com', + onExit, + ...overrides, + }; + + render( + + + , + ); + + return { send, onExit }; +} + +describe('OrganizationProfileSecurityWizardActivateView', () => { + it('fires ENTER once on mount', () => { + const { send } = renderView(); + expect(send).toHaveBeenCalledWith({ type: 'ENTER' }); + expect(send.mock.calls.filter(([event]) => event.type === 'ENTER')).toHaveLength(1); + }); + + it('activates the connection from the Activate button', () => { + const { send } = renderView({ isActive: false }); + fireEvent.click(screen.getByRole('button', { name: 'Activate SSO' })); + expect(send).toHaveBeenCalledWith({ type: 'ACTIVATE' }); + }); + + it('skips out of the wizard without activating', () => { + const { send, onExit } = renderView({ isActive: false }); + fireEvent.click(screen.getByRole('button', { name: 'Skip for now' })); + expect(onExit).toHaveBeenCalled(); + expect(send).not.toHaveBeenCalledWith({ type: 'ACTIVATE' }); + }); + + it('shows the active copy and a Done exit when the connection is already active', () => { + const { onExit } = renderView({ isActive: true }); + expect(screen.getByText('SSO connection is active')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Activate SSO' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Done' })); + expect(onExit).toHaveBeenCalled(); + }); + + it('shows the activation error', () => { + renderView({ snapshot: snapshot('idle', { error: 'Activation failed' }) }); + expect(screen.getByRole('alert')).toHaveTextContent('Activation failed'); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-configure.equivalence.test.tsx b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-configure.equivalence.test.tsx new file mode 100644 index 00000000000..0935b026306 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-configure.equivalence.test.tsx @@ -0,0 +1,169 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { WizardContextValue } from '@/components/ConfigureSSO/elements/Wizard/types'; +import { useWizardMachine } from '@/components/ConfigureSSO/elements/Wizard/useWizardMachine'; +import type { ProviderType } from '@/components/ConfigureSSO/types'; + +import { createActor } from '../../machine/createActor'; +import type { OrganizationProfileSecurityWizardConfigureContext } from '../organization-profile-security-wizard-configure.machine'; +import { + CONFIGURE_PROVIDER_STEPS, + organizationProfileSecurityWizardConfigureMachine, + SAML_SUBMIT_STEP_ID, +} from '../organization-profile-security-wizard-configure.machine'; + +/** + * 1:1 equivalence proof for the CONFIGURE step's NESTED wizard. + * + * The legacy configure step nests THREE generic ``s: the outer 4-step wizard, + * a MIDDLE `configure` wizard (`select-provider → configure-provider`), and an INNER + * per-provider SAML wizard (`steps/ConfigureStep/saml/*`) whose terminal `goNext` + * BUBBLES up through the middle wizard to advance the OUTER wizard to `test` + * (`useWizardMachine.ts:164-176`). Each SAML wizard mounts on its first step + * (`initialStepId={STEPS[0].id}`, e.g. `SamlOktaConfigureSteps.tsx`). + * + * The Mosaic migration collapses the middle + inner levels into ONE machine + * (`organization-profile-security-wizard-configure.machine.ts`) where the "bubble" + * is the controller forwarding a single `NEXT` to the outer wizard actor on the rising + * edge into `bubblingNext`. This test drives the LEGACY inner `useWizardMachine` seam + * (the oracle) and the NEW collapsed machine through the same inner walk for every + * provider, and asserts they reach the same boundary behavior: + * - a terminal inner advance bubbles to the parent (legacy) ⟺ the new machine reaches + * its bubble point / terminal inner step at the same position; + * - a first-inner back-nav bubbles to `parent.goPrev` (legacy) ⟺ the new machine + * returns to `selecting`. + * + * The per-provider step arrays used to drive BOTH sides are `CONFIGURE_PROVIDER_STEPS`, + * which a sibling test (`*-configure.machine.test.ts`) pins byte-for-byte to the legacy + * `Saml*ConfigureSteps.tsx` arrays — so driving both from this constant is faithful. + */ + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +/** A no-op parent stub whose nav methods we can spy on (mirrors the legacy hook test). */ +const makeParent = (overrides: Partial = {}): WizardContextValue => ({ + current: 'parent', + activeSteps: [], + currentStep: undefined, + currentIndex: 0, + direction: 0, + totalSteps: 0, + isInitialStep: true, + isNested: false, + isFirstStep: true, + isLastStep: true, + goNext: vi.fn(), + goPrev: vi.fn(), + goToStep: vi.fn(), + ...overrides, +}); + +/** The legacy inner SAML wizard: guard-less linear steps, seeded on the first (as the real code does). */ +const renderLegacyInner = (steps: readonly string[], parent: WizardContextValue) => + renderHook(() => + useWizardMachine({ + config: { descriptors: steps.map(id => ({ id })) }, + parentWizard: parent, + initialStepId: steps[0], + }), + ); + +/** The new collapsed configure machine, warmed with a connection and skipped into the SAML sub-flow. */ +const startNewConfigure = (steps: readonly string[]) => { + const context: Partial = { + hasConnection: true, + providerSteps: [...steps], + submitIndex: steps.indexOf(SAML_SUBMIT_STEP_ID), + createConnection: async () => {}, + changeProvider: async () => {}, + updateConnection: async () => {}, + }; + const actor = createActor(organizationProfileSecurityWizardConfigureMachine, { context }); + actor.start(); + actor.send({ type: 'SKIP' }); + return actor; +}; + +const PROVIDERS: { name: string; provider: ProviderType }[] = [ + { name: 'okta', provider: 'saml_okta' }, + { name: 'custom', provider: 'saml_custom' }, + { name: 'microsoft', provider: 'saml_microsoft' }, + { name: 'google', provider: 'saml_google' }, +]; + +describe.each(PROVIDERS)('configure nested seam ⟺ legacy useWizardMachine — $name', ({ provider }) => { + const steps = CONFIGURE_PROVIDER_STEPS[provider]; + const lastIndex = steps.length - 1; + const submitIndex = steps.indexOf(SAML_SUBMIT_STEP_ID); + + it('a terminal inner advance bubbles to the parent (legacy) and the new machine completes the inner flow at the same terminal step', async () => { + // ── Legacy oracle: the inner SAML wizard bubbles at its terminal step ── + const parent = makeParent(); + const { result } = renderLegacyInner(steps, parent); + expect(result.current.current).toBe(steps[0]); + for (let i = 0; i < lastIndex; i++) { + await act(() => result.current.goNext()); + } + expect(result.current.current).toBe(steps[lastIndex]); + expect(result.current.isLastStep).toBe(true); + // The terminal inner goNext is a scope boundary: it bubbles to the parent (the middle + // wizard, which itself bubbles to the outer wizard → `test`). + await act(() => result.current.goNext()); + expect(parent.goNext).toHaveBeenCalledTimes(1); + + // ── New machine: the collapsed configure machine reaches the same boundary ── + const actor = startNewConfigure(steps); + for (let i = 0; i < submitIndex; i++) { + actor.send({ type: 'NEXT_INNER' }); + } + actor.send({ type: 'SAVE', payload: {} }); + await tick(); + + if (submitIndex === lastIndex) { + // Terminal submit (Okta / Custom / Microsoft): advancing off the end IS the bubble. + expect(actor.getSnapshot().value).toBe('bubblingNext'); + } else { + // Mid-flow submit (Google): advance one inner slot, then walk to the terminal step, + // whose plain Continue is the view-forwarded bubble — same net "inner completion → outer". + expect(actor.getSnapshot().value).toBe('configuring'); + expect(actor.getSnapshot().context.stepIndex).toBe(submitIndex + 1); + for (let i = submitIndex + 1; i < lastIndex; i++) { + actor.send({ type: 'NEXT_INNER' }); + } + expect(actor.getSnapshot().context.stepIndex).toBe(lastIndex); + } + }); + + it('a mid-flow blocked-less advance does NOT bubble early (legacy) and the new machine stays within the inner flow', async () => { + // Legacy: a non-terminal goNext advances one slot and never touches the parent. + const parent = makeParent(); + const { result } = renderLegacyInner(steps, parent); + await act(() => result.current.goNext()); + expect(result.current.current).toBe(steps[1]); + expect(parent.goNext).not.toHaveBeenCalled(); + + // New machine: NEXT_INNER from step 0 advances to step 1, staying in `configuring`. + const actor = startNewConfigure(steps); + actor.send({ type: 'NEXT_INNER' }); + expect(actor.getSnapshot().value).toBe('configuring'); + expect(actor.getSnapshot().context.stepIndex).toBe(1); + }); + + it('a first-inner back-nav backs out of the SAML sub-flow (legacy bubbles to parent.goPrev; the new machine returns to select-provider)', async () => { + const parent = makeParent(); + const { result } = renderLegacyInner(steps, parent); + expect(result.current.current).toBe(steps[0]); + expect(result.current.isFirstStep).toBe(true); + // First-position goPrev is the other scope boundary: it bubbles to the middle wizard, + // which steps configure-provider → select-provider. + await act(() => result.current.goPrev()); + expect(parent.goPrev).toHaveBeenCalledTimes(1); + + // New machine: PREV_INNER at inner step 0 returns to `selecting` (the collapsed equivalent). + const actor = startNewConfigure(steps); + expect(actor.getSnapshot().context.stepIndex).toBe(0); + actor.send({ type: 'PREV_INNER' }); + expect(actor.getSnapshot().value).toBe('selecting'); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-configure.machine.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-configure.machine.test.ts new file mode 100644 index 00000000000..cf9622b23b1 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-configure.machine.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { tick } from '../../machines/__tests__/test-utils'; +import type { OrganizationProfileSecurityWizardConfigureContext } from '../organization-profile-security-wizard-configure.machine'; +import { + CONFIGURE_PROVIDER_STEPS, + organizationProfileSecurityWizardConfigureMachine, + SAML_SUBMIT_STEP_ID, +} from '../organization-profile-security-wizard-configure.machine'; + +// Ordered inner SAML steps per provider (matches the controller's PROVIDER_STEPS). Okta submits at +// its terminal step; Google submits mid-flow (index 1 of 5). +const OKTA_STEPS = ['create-app', 'attribute-mapping', 'assign-users', 'identity-provider-metadata']; +const GOOGLE_STEPS = [ + 'create-app', + 'identity-provider-metadata', + 'service-provider', + 'attribute-mapping', + 'configure-user-access', +]; + +function start(context: Partial = {}) { + const createConnection = vi.fn(() => Promise.resolve()); + const changeProvider = vi.fn(() => Promise.resolve()); + const updateConnection = vi.fn(() => Promise.resolve()); + const actor = createActor(organizationProfileSecurityWizardConfigureMachine, { + context: { createConnection, changeProvider, updateConnection, ...context }, + }); + actor.start(); + return { actor, createConnection, changeProvider, updateConnection }; +} + +describe('organizationProfileSecurityWizardConfigureMachine — select provider', () => { + it('seeds at selecting with no connection', () => { + const { actor } = start({ hasConnection: false }); + expect(actor.getSnapshot().value).toBe('selecting'); + }); + + it('creates a connection, then defers the advance until the fresh hasConnection lands', async () => { + const { actor, createConnection } = start({ hasConnection: false, providerSteps: OKTA_STEPS, submitIndex: 3 }); + + actor.send({ type: 'CREATE', provider: 'saml_okta' }); + expect(actor.getSnapshot().value).toBe('creatingConnection'); + expect(createConnection).toHaveBeenCalledWith('saml_okta'); + + // createConnection resolves, but hasConnection is still stale-false: the advance parks and the + // Continue spinner stays on (legacy middle-wizard deferral). + await tick(); + expect(actor.getSnapshot().value).toBe('creatingConnection'); + + // The controller reseats the fresh connection data and rechecks — now the advance completes. + actor.setContext({ hasConnection: true }); + actor.recheck(); + + expect(actor.getSnapshot().value).toBe('configuring'); + expect(actor.getSnapshot().context.stepIndex).toBe(0); + }); + + it('advances immediately when hasConnection is already warm at create time', async () => { + const { actor } = start({ hasConnection: true, providerSteps: OKTA_STEPS, submitIndex: 3 }); + actor.send({ type: 'ENTER', forward: true }); + + actor.send({ type: 'CREATE', provider: 'saml_okta' }); + await tick(); + + expect(actor.getSnapshot().value).toBe('configuring'); + }); + + it('surfaces the error and stays on select-provider when create fails', async () => { + const createConnection = vi.fn(() => Promise.reject(new Error('Provider unavailable'))); + const { actor } = start({ hasConnection: false, createConnection }); + + actor.send({ type: 'CREATE', provider: 'saml_okta' }); + await tick(); + + expect(actor.getSnapshot().value).toBe('selecting'); + expect(actor.getSnapshot().context.error).toBe('Provider unavailable'); + }); + + it('changes the provider and enters the SAML sub-flow', async () => { + const { actor, changeProvider } = start({ hasConnection: true, providerSteps: OKTA_STEPS, submitIndex: 3 }); + + // Force select-provider first (a resume seeds configuring). + actor.send({ type: 'ENTER', forward: true }); + expect(actor.getSnapshot().value).toBe('selecting'); + + actor.send({ type: 'CHANGE', provider: 'saml_google' }); + expect(changeProvider).toHaveBeenCalledWith('saml_google'); + + await tick(); + + expect(actor.getSnapshot().value).toBe('configuring'); + }); + + it('defers the change advance until the fresh hasConnection lands (non-atomic delete+create swap)', async () => { + const { actor, changeProvider } = start({ hasConnection: true, providerSteps: OKTA_STEPS, submitIndex: 3 }); + actor.send({ type: 'ENTER', forward: true }); + + actor.send({ type: 'CHANGE', provider: 'saml_google' }); + expect(changeProvider).toHaveBeenCalledWith('saml_google'); + + // changeProvider deletes the old connection before creating the new one, so + // hasConnection is transiently false when it resolves — the advance parks and the + // Continue spinner stays on, exactly like the create path's revalidation race. + actor.setContext({ hasConnection: false }); + await tick(); + expect(actor.getSnapshot().value).toBe('changingProvider'); + expect(actor.getSnapshot().context.pendingEnter).toBe(true); + + // The revalidate lands with the new connection; recheck completes the parked advance. + actor.setContext({ hasConnection: true }); + actor.recheck(); + expect(actor.getSnapshot().value).toBe('configuring'); + expect(actor.getSnapshot().context.pendingEnter).toBe(false); + }); + + it('closes back to select-provider with an error when the provider change fails', async () => { + // The non-atomic delete+create swap can fail after dropping the old connection. Legacy closed the + // ChangeProviderDialog and surfaced the error on the step without advancing; here that is the + // symmetric partner of the CREATE-failure path (changingProvider.onError → selecting + error). + const changeProvider = vi.fn(() => Promise.reject(new Error('Swap failed'))); + const { actor } = start({ hasConnection: true, providerSteps: OKTA_STEPS, submitIndex: 3, changeProvider }); + actor.send({ type: 'ENTER', forward: true }); + expect(actor.getSnapshot().value).toBe('selecting'); + + actor.send({ type: 'CHANGE', provider: 'saml_google' }); + expect(changeProvider).toHaveBeenCalledWith('saml_google'); + await tick(); + + expect(actor.getSnapshot().value).toBe('selecting'); + expect(actor.getSnapshot().context.error).toBe('Swap failed'); + expect(actor.getSnapshot().context.pendingEnter).toBe(false); + }); + + it('skips straight into the SAML sub-flow for the same provider', () => { + const { actor, createConnection, changeProvider } = start({ hasConnection: true }); + actor.send({ type: 'ENTER', forward: true }); + + actor.send({ type: 'SKIP' }); + + expect(actor.getSnapshot().value).toBe('configuring'); + expect(createConnection).not.toHaveBeenCalled(); + expect(changeProvider).not.toHaveBeenCalled(); + }); +}); + +describe('organizationProfileSecurityWizardConfigureMachine — entry re-seat', () => { + it('forces select-provider on forward entry even with a connection', () => { + const { actor } = start({ hasConnection: true }); + actor.send({ type: 'ENTER', forward: true }); + expect(actor.getSnapshot().value).toBe('selecting'); + }); + + it('resumes into the SAML sub-flow on non-forward entry with a connection', () => { + const { actor } = start({ hasConnection: true, providerSteps: OKTA_STEPS, submitIndex: 3, stepIndex: 2 }); + actor.send({ type: 'ENTER', forward: false }); + expect(actor.getSnapshot().value).toBe('configuring'); + // Re-entry always resets the inner step to 0 (legacy remounts the SAML wizard fresh). + expect(actor.getSnapshot().context.stepIndex).toBe(0); + }); + + it('re-seats to select-provider when the connection is deleted underneath (recheck)', () => { + const { actor } = start({ hasConnection: true, providerSteps: OKTA_STEPS, submitIndex: 3 }); + actor.send({ type: 'SKIP' }); + expect(actor.getSnapshot().value).toBe('configuring'); + + actor.setContext({ hasConnection: false }); + actor.recheck(); + + expect(actor.getSnapshot().value).toBe('selecting'); + }); +}); + +describe('organizationProfileSecurityWizardConfigureMachine — inner navigation', () => { + it('advances and retreats within the inner SAML steps', () => { + const { actor } = start({ hasConnection: true, providerSteps: OKTA_STEPS, submitIndex: 3 }); + actor.send({ type: 'SKIP' }); + + actor.send({ type: 'NEXT_INNER' }); + expect(actor.getSnapshot().context.stepIndex).toBe(1); + + actor.send({ type: 'PREV_INNER' }); + expect(actor.getSnapshot().context.stepIndex).toBe(0); + }); + + it('backs out to select-provider from the first inner step', () => { + const { actor } = start({ hasConnection: true, providerSteps: OKTA_STEPS, submitIndex: 3 }); + actor.send({ type: 'SKIP' }); + + actor.send({ type: 'PREV_INNER' }); + expect(actor.getSnapshot().value).toBe('selecting'); + }); +}); + +describe('organizationProfileSecurityWizardConfigureMachine — save + bubble', () => { + it('bubbles to the parent when the terminal step saves (Okta)', async () => { + const { actor, updateConnection } = start({ + hasConnection: true, + providerSteps: OKTA_STEPS, + submitIndex: 3, + }); + actor.send({ type: 'SKIP' }); + // Walk to the terminal submit step. + actor.send({ type: 'NEXT_INNER' }); + actor.send({ type: 'NEXT_INNER' }); + actor.send({ type: 'NEXT_INNER' }); + expect(actor.getSnapshot().context.stepIndex).toBe(3); + + actor.send({ type: 'SAVE', payload: { idpMetadataUrl: 'https://idp.example.com/metadata' } }); + expect(actor.getSnapshot().value).toBe('saving'); + expect(updateConnection).toHaveBeenCalledWith({ idpMetadataUrl: 'https://idp.example.com/metadata' }); + + await tick(); + + expect(actor.getSnapshot().value).toBe('bubblingNext'); + }); + + it('advances to the next inner step when a mid-flow step saves (Google)', async () => { + const { actor } = start({ hasConnection: true, providerSteps: GOOGLE_STEPS, submitIndex: 1 }); + actor.send({ type: 'SKIP' }); + actor.send({ type: 'NEXT_INNER' }); + expect(actor.getSnapshot().context.stepIndex).toBe(1); + + actor.send({ type: 'SAVE', payload: { idpMetadataUrl: 'https://idp.example.com/metadata' } }); + await tick(); + + expect(actor.getSnapshot().value).toBe('configuring'); + expect(actor.getSnapshot().context.stepIndex).toBe(2); + }); + + it('returns to the step with an error when a save fails', async () => { + const updateConnection = vi.fn(() => Promise.reject(new Error('Invalid metadata URL'))); + const { actor } = start({ hasConnection: true, providerSteps: OKTA_STEPS, submitIndex: 3, updateConnection }); + actor.send({ type: 'SKIP' }); + actor.send({ type: 'NEXT_INNER' }); + actor.send({ type: 'NEXT_INNER' }); + actor.send({ type: 'NEXT_INNER' }); + + actor.send({ type: 'SAVE', payload: {} }); + await tick(); + + expect(actor.getSnapshot().value).toBe('configuring'); + expect(actor.getSnapshot().context.stepIndex).toBe(3); + expect(actor.getSnapshot().context.error).toBe('Invalid metadata URL'); + }); +}); + +describe('organizationProfileSecurityWizardConfigureMachine — per-provider save terminal (all 4 providers, 1:1 with legacy STEPS_BY_PROVIDER)', () => { + // Transcribed verbatim from the legacy per-provider step arrays; the submit step is always + // `identity-provider-metadata` — terminal for Okta/Custom/Microsoft, mid-flow (index 1 of 5) for Google: + // SamlOktaConfigureSteps.tsx:39-44, SamlCustomConfigureSteps.tsx:38-43, + // SamlMicrosoftConfigureSteps.tsx:47-52, SamlGoogleConfigureSteps.tsx:26-32. + const PROVIDERS = [ + { name: 'okta', steps: CONFIGURE_PROVIDER_STEPS.saml_okta, terminal: true }, + { name: 'custom', steps: CONFIGURE_PROVIDER_STEPS.saml_custom, terminal: true }, + { name: 'microsoft', steps: CONFIGURE_PROVIDER_STEPS.saml_microsoft, terminal: true }, + { name: 'google', steps: CONFIGURE_PROVIDER_STEPS.saml_google, terminal: false }, + ] as const; + + it.each(PROVIDERS)( + '$name: saving at its submit step bubbles (terminal) or advances one inner slot (mid-flow) exactly as legacy', + async ({ steps, terminal }) => { + const submitIndex = steps.indexOf(SAML_SUBMIT_STEP_ID); + const { actor, updateConnection } = start({ hasConnection: true, providerSteps: [...steps], submitIndex }); + actor.send({ type: 'SKIP' }); + + // Walk the inner SAML steps up to the submit step. + for (let i = 0; i < submitIndex; i++) { + actor.send({ type: 'NEXT_INNER' }); + } + expect(actor.getSnapshot().context.stepIndex).toBe(submitIndex); + + actor.send({ type: 'SAVE', payload: { idpMetadataUrl: 'https://idp.example.com/metadata' } }); + await tick(); + expect(updateConnection).toHaveBeenCalledTimes(1); + + if (terminal) { + // Advancing off the end IS the bubble to the outer wizard's `test` step. + expect(actor.getSnapshot().value).toBe('bubblingNext'); + } else { + // Google submits mid-flow: advance to the next inner step; the later terminal + // step's plain Continue is the view-forwarded bubble. + expect(actor.getSnapshot().value).toBe('configuring'); + expect(actor.getSnapshot().context.stepIndex).toBe(submitIndex + 1); + } + }, + ); + + it('the ported CONFIGURE_PROVIDER_STEPS + submit step match the legacy per-provider arrays 1:1', () => { + expect(CONFIGURE_PROVIDER_STEPS.saml_okta).toEqual([ + 'create-app', + 'attribute-mapping', + 'assign-users', + 'identity-provider-metadata', + ]); + expect(CONFIGURE_PROVIDER_STEPS.saml_custom).toEqual([ + 'create-app', + 'attribute-mapping', + 'assign-users', + 'identity-provider-metadata', + ]); + expect(CONFIGURE_PROVIDER_STEPS.saml_microsoft).toEqual([ + 'create-app', + 'service-provider', + 'attribute-mapping', + 'identity-provider-metadata', + ]); + expect(CONFIGURE_PROVIDER_STEPS.saml_google).toEqual([ + 'create-app', + 'identity-provider-metadata', + 'service-provider', + 'attribute-mapping', + 'configure-user-access', + ]); + // Terminal submit for all but Google (index 1 of 5). + expect(CONFIGURE_PROVIDER_STEPS.saml_okta.indexOf(SAML_SUBMIT_STEP_ID)).toBe(3); + expect(CONFIGURE_PROVIDER_STEPS.saml_custom.indexOf(SAML_SUBMIT_STEP_ID)).toBe(3); + expect(CONFIGURE_PROVIDER_STEPS.saml_microsoft.indexOf(SAML_SUBMIT_STEP_ID)).toBe(3); + expect(CONFIGURE_PROVIDER_STEPS.saml_google.indexOf(SAML_SUBMIT_STEP_ID)).toBe(1); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-configure.view.test.tsx b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-configure.view.test.tsx new file mode 100644 index 00000000000..77a883b7bec --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-configure.view.test.tsx @@ -0,0 +1,174 @@ +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 { OrganizationProfileSecurityWizardConfigureStep } from '../organization-profile-security-panel.controller'; +import type { OrganizationProfileSecurityWizardConfigureContext } from '../organization-profile-security-wizard-configure.machine'; +import { OrganizationProfileSecurityWizardConfigureView } from '../organization-profile-security-wizard-configure.view'; + +const OKTA_STEPS = ['create-app', 'attribute-mapping', 'assign-users', 'identity-provider-metadata']; + +const snapshot = ( + value: string, + context: Partial = {}, +): Snapshot => ({ + value, + status: 'active', + context: { + providerSteps: [], + submitIndex: -1, + stepIndex: 0, + direction: 0, + hasConnection: false, + error: null, + pendingEnter: false, + pendingProvider: null, + pendingPayload: {}, + createConnection: async () => {}, + changeProvider: async () => {}, + updateConnection: async () => {}, + ...context, + }, +}); + +function renderView(overrides: Partial = {}) { + const send = vi.fn(); + const onParentNext = vi.fn(); + const onParentPrev = vi.fn(); + + const configure: OrganizationProfileSecurityWizardConfigureStep = { + snapshot: snapshot('selecting'), + send, + provider: undefined, + providerSteps: [], + submitStepId: 'identity-provider-metadata', + enteredForward: true, + onParentNext, + onParentPrev, + ...overrides, + }; + + render( + + + , + ); + + return { send, onParentNext, onParentPrev }; +} + +describe('OrganizationProfileSecurityWizardConfigureView — mount', () => { + it('fires ENTER once with the entry direction', () => { + const { send } = renderView({ enteredForward: true }); + expect(send).toHaveBeenCalledWith({ type: 'ENTER', forward: true }); + expect(send.mock.calls.filter(([event]) => event.type === 'ENTER')).toHaveLength(1); + }); +}); + +describe('OrganizationProfileSecurityWizardConfigureView — select provider', () => { + it('creates a connection for a fresh selection', () => { + const { send } = renderView({ provider: undefined }); + fireEvent.click(screen.getByRole('button', { name: 'Okta' })); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(send).toHaveBeenCalledWith({ type: 'CREATE', provider: 'saml_okta' }); + }); + + it('skips straight in when the same provider is reselected', () => { + const { send } = renderView({ provider: 'saml_okta' }); + // saml_okta arrives pre-selected; Continue with the same provider skips. + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(send).toHaveBeenCalledWith({ type: 'SKIP' }); + }); + + it('confirms a provider change through the dialog before sending CHANGE', () => { + const { send } = renderView({ provider: 'saml_okta' }); + fireEvent.click(screen.getByRole('button', { name: 'Google' })); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + // CHANGE is not sent until the dialog is confirmed. + expect(send).not.toHaveBeenCalledWith({ type: 'CHANGE', provider: 'saml_google' }); + + fireEvent.click(screen.getByRole('button', { name: 'Change provider' })); + expect(send).toHaveBeenCalledWith({ type: 'CHANGE', provider: 'saml_google' }); + }); + + it('forwards Back to the outer wizard', () => { + const { onParentPrev } = renderView(); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(onParentPrev).toHaveBeenCalled(); + }); +}); + +describe('OrganizationProfileSecurityWizardConfigureView — SAML sub-flow', () => { + it('advances a non-submit inner step with NEXT_INNER', () => { + const { send } = renderView({ + snapshot: snapshot('configuring', { + hasConnection: true, + providerSteps: OKTA_STEPS, + submitIndex: 3, + stepIndex: 0, + }), + providerSteps: OKTA_STEPS, + }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(send).toHaveBeenCalledWith({ type: 'NEXT_INNER' }); + }); + + it('submits the metadata URL on the submit step', () => { + const { send } = renderView({ + snapshot: snapshot('configuring', { + hasConnection: true, + providerSteps: OKTA_STEPS, + submitIndex: 3, + stepIndex: 3, + }), + providerSteps: OKTA_STEPS, + }); + fireEvent.change(screen.getByRole('textbox', { name: 'Identity provider metadata URL' }), { + target: { value: 'https://idp.example.com/metadata' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(send).toHaveBeenCalledWith({ + type: 'SAVE', + payload: { idpMetadataUrl: 'https://idp.example.com/metadata' }, + }); + }); + + it('steps back within the SAML sub-flow with PREV_INNER', () => { + const { send } = renderView({ + snapshot: snapshot('configuring', { + hasConnection: true, + providerSteps: OKTA_STEPS, + submitIndex: 3, + stepIndex: 2, + }), + providerSteps: OKTA_STEPS, + }); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(send).toHaveBeenCalledWith({ type: 'PREV_INNER' }); + }); + + it('bubbles to the outer wizard from a terminal non-submit step', () => { + // Google's configure-user-access is terminal but not the submit step. + const googleSteps = [ + 'create-app', + 'identity-provider-metadata', + 'service-provider', + 'attribute-mapping', + 'configure-user-access', + ]; + const { onParentNext, send } = renderView({ + snapshot: snapshot('configuring', { + hasConnection: true, + providerSteps: googleSteps, + submitIndex: 1, + stepIndex: 4, + }), + providerSteps: googleSteps, + }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(onParentNext).toHaveBeenCalled(); + expect(send).not.toHaveBeenCalledWith({ type: 'NEXT_INNER' }); + }); +}); 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-test.machine.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-test.machine.test.ts new file mode 100644 index 00000000000..64c75acb7d0 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-test.machine.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { tick } from '../../machines/__tests__/test-utils'; +import type { OrganizationProfileSecurityWizardTestContext } from '../organization-profile-security-wizard-test.machine'; +import { organizationProfileSecurityWizardTestMachine } from '../organization-profile-security-wizard-test.machine'; + +const NO_RUN_MESSAGE = 'Run a test and complete it successfully before continuing.'; + +function start(context: Partial = {}) { + const createTestRun = vi.fn(() => Promise.resolve()); + const revalidateHasSuccessfulTestRun = vi.fn(() => Promise.resolve(false)); + const actor = createActor(organizationProfileSecurityWizardTestMachine, { + context: { + noSuccessfulRunMessage: NO_RUN_MESSAGE, + createTestRun, + revalidateHasSuccessfulTestRun, + ...context, + }, + }); + actor.start(); + return { actor, createTestRun, revalidateHasSuccessfulTestRun }; +} + +describe('organizationProfileSecurityWizardTestMachine — create run', () => { + it('seeds at idle', () => { + const { actor } = start(); + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('creates a test run and returns to idle', async () => { + const { actor, createTestRun } = start(); + + actor.send({ type: 'CREATE_RUN' }); + expect(actor.getSnapshot().value).toBe('creatingRun'); + expect(createTestRun).toHaveBeenCalledTimes(1); + + await tick(); + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('surfaces the error and returns to idle when creating a run fails', async () => { + const createTestRun = vi.fn(() => Promise.reject(new Error('Connection unreachable'))); + const { actor } = start({ createTestRun }); + + actor.send({ type: 'CREATE_RUN' }); + await tick(); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.error).toBe('Connection unreachable'); + }); +}); + +describe('organizationProfileSecurityWizardTestMachine — continue', () => { + it('bubbles straight to the parent when a successful run is already known', () => { + const { actor, revalidateHasSuccessfulTestRun } = start({ hasSuccessfulTestRun: true }); + + actor.send({ type: 'CONTINUE' }); + + expect(actor.getSnapshot().value).toBe('bubblingNext'); + expect(revalidateHasSuccessfulTestRun).not.toHaveBeenCalled(); + }); + + it('revalidates the success probe on continue and bubbles when it now passes', async () => { + const revalidateHasSuccessfulTestRun = vi.fn(() => Promise.resolve(true)); + const { actor } = start({ hasSuccessfulTestRun: false, revalidateHasSuccessfulTestRun }); + + actor.send({ type: 'CONTINUE' }); + expect(actor.getSnapshot().value).toBe('validating'); + expect(revalidateHasSuccessfulTestRun).toHaveBeenCalledTimes(1); + + await tick(); + expect(actor.getSnapshot().value).toBe('bubblingNext'); + }); + + it('shows the no-successful-run message when the revalidated probe is still empty', async () => { + const { actor } = start({ hasSuccessfulTestRun: false }); + + actor.send({ type: 'CONTINUE' }); + await tick(); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.error).toBe(NO_RUN_MESSAGE); + }); + + it('surfaces the error and returns to idle when the revalidation throws', async () => { + const revalidateHasSuccessfulTestRun = vi.fn(() => Promise.reject(new Error('Probe failed'))); + const { actor } = start({ hasSuccessfulTestRun: false, revalidateHasSuccessfulTestRun }); + + actor.send({ type: 'CONTINUE' }); + await tick(); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.error).toBe('Probe failed'); + }); + + it('clears a stale error when the fast-path advance bubbles', () => { + const { actor } = start({ hasSuccessfulTestRun: true, error: 'stale' }); + + actor.send({ type: 'CONTINUE' }); + + expect(actor.getSnapshot().value).toBe('bubblingNext'); + expect(actor.getSnapshot().context.error).toBeNull(); + }); +}); + +describe('organizationProfileSecurityWizardTestMachine — re-entry', () => { + it('resets back to idle and clears the error on ENTER', async () => { + const { actor } = start({ hasSuccessfulTestRun: true }); + actor.send({ type: 'CONTINUE' }); + expect(actor.getSnapshot().value).toBe('bubblingNext'); + + // Re-mounting the step (e.g. a PREV back from activate) resets the flow. + actor.send({ type: 'ENTER' }); + expect(actor.getSnapshot().value).toBe('idle'); + + // A prior error is cleared on re-entry. + const failed = start({ hasSuccessfulTestRun: false }); + failed.actor.send({ type: 'CONTINUE' }); + await tick(); + expect(failed.actor.getSnapshot().context.error).toBe(NO_RUN_MESSAGE); + failed.actor.send({ type: 'ENTER' }); + expect(failed.actor.getSnapshot().context.error).toBeNull(); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-test.view.test.tsx b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-test.view.test.tsx new file mode 100644 index 00000000000..159b5c161dd --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard-test.view.test.tsx @@ -0,0 +1,130 @@ +import type { EnterpriseConnectionTestRunResource } from '@clerk/shared/types'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { TestRunsView } from '@/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; + +import type { Snapshot } from '../../machine/types'; +import { MosaicProvider } from '../../MosaicProvider'; +import type { OrganizationProfileSecurityWizardTestStep } from '../organization-profile-security-panel.controller'; +import type { OrganizationProfileSecurityWizardTestContext } from '../organization-profile-security-wizard-test.machine'; +import { OrganizationProfileSecurityWizardTestView } from '../organization-profile-security-wizard-test.view'; + +const snapshot = ( + value: string, + context: Partial = {}, +): Snapshot => ({ + value, + status: 'active', + context: { + hasSuccessfulTestRun: false, + error: null, + noSuccessfulRunMessage: 'no successful run', + createTestRun: async () => {}, + revalidateHasSuccessfulTestRun: async () => false, + ...context, + }, +}); + +const row = (overrides: Partial = {}): EnterpriseConnectionTestRunResource => { + const fixture = { + id: 'tr_1', + status: 'success', + createdAt: null, + logs: [], + parsedUserInfo: null, + ...overrides, + }; + // SAFETY: the view reads only id/status/parsedUserInfo/logs off a run; this partial fixture covers + // those and the test never touches the resource's methods, so the boundary cast is safe here. + return fixture as unknown as EnterpriseConnectionTestRunResource; +}; + +const testRunsView = (overrides: Partial = {}): TestRunsView => ({ + rows: [], + totalCount: 0, + isLoading: false, + isFetching: false, + isPolling: false, + page: 1, + setPage: vi.fn(), + refresh: vi.fn(() => Promise.resolve()), + revalidateHasSuccessfulTestRun: vi.fn(() => Promise.resolve(false)), + ...overrides, +}); + +function renderView(overrides: Partial = {}) { + const send = vi.fn(); + const onParentPrev = vi.fn(); + + const test: OrganizationProfileSecurityWizardTestStep = { + snapshot: snapshot('idle'), + send, + testRuns: testRunsView(), + onParentPrev, + ...overrides, + }; + + render( + + + , + ); + + return { send, onParentPrev, test }; +} + +describe('OrganizationProfileSecurityWizardTestView — mount', () => { + it('fires ENTER once on mount', () => { + const { send } = renderView(); + expect(send).toHaveBeenCalledWith({ type: 'ENTER' }); + expect(send.mock.calls.filter(([event]) => event.type === 'ENTER')).toHaveLength(1); + }); +}); + +describe('OrganizationProfileSecurityWizardTestView — actions', () => { + it('creates a test run from the open-test-url button', () => { + const { send } = renderView(); + fireEvent.click(screen.getByRole('button', { name: 'Open test URL' })); + expect(send).toHaveBeenCalledWith({ type: 'CREATE_RUN' }); + }); + + it('refreshes the logs WITHOUT arming polling', () => { + const refresh = vi.fn(() => Promise.resolve()); + renderView({ testRuns: testRunsView({ refresh }) }); + fireEvent.click(screen.getByRole('button', { name: 'Refresh logs' })); + // The manual refresh must never arm polling — only creating a run does. + expect(refresh).toHaveBeenCalledTimes(1); + expect(refresh).toHaveBeenCalledWith(); + }); + + it('continues via the footer', () => { + const { send } = renderView(); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + expect(send).toHaveBeenCalledWith({ type: 'CONTINUE' }); + }); + + it('forwards Back to the outer wizard', () => { + const { onParentPrev } = renderView(); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(onParentPrev).toHaveBeenCalled(); + }); +}); + +describe('OrganizationProfileSecurityWizardTestView — results', () => { + it('renders the test-run rows with their status', () => { + renderView({ + testRuns: testRunsView({ + rows: [row({ id: 'tr_1', status: 'success' }), row({ id: 'tr_2', status: 'failed' })], + totalCount: 2, + }), + }); + expect(screen.getByText('success')).toBeInTheDocument(); + expect(screen.getByText('failed')).toBeInTheDocument(); + }); + + it('shows the error message from the machine', () => { + renderView({ snapshot: snapshot('idle', { error: 'You need at least one successful test run.' }) }); + expect(screen.getByRole('alert')).toHaveTextContent('You need at least one successful test run.'); + }); +}); 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..8b73388e304 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-security-wizard.machine.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import type { OrganizationProfileSecurityWizardContext } from '../organization-profile-security-wizard.machine'; +import { + isSecurityWizardStepComplete, + 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'); + }); + + it('an explicit PREV abandons a parked advance (legacy goPrev clears pendingNextFrom)', () => { + // On configure (reachable) with `test` still closed: NEXT parks. + const actor = actorAt('configure', { allDomainsVerified: true, hasConnection: true }); + actor.send({ type: 'NEXT' }); + expect(actor.getSnapshot().value).toBe('configure'); + expect(actor.getSnapshot().context.pendingNext).toBe(true); + + // Back out. The parked advance must be abandoned (navigated(-1) clears pendingNext). + actor.send({ type: 'PREV' }); + expect(actor.getSnapshot().value).toBe('verify-domain'); + expect(actor.getSnapshot().context.pendingNext).toBe(false); + + // And it must NOT resurrect once `test` later opens — the user chose to step back. + actor.setContext({ hasMinimumConfiguration: true }); + actor.recheck(); + expect(actor.getSnapshot().value).toBe('verify-domain'); + }); + + it('keeps a parked advance across a recheck where the guard is still closed, then resolves on a later one', () => { + const actor = actorAt('verify-domain', {}); + actor.send({ type: 'NEXT' }); + expect(actor.getSnapshot().context.pendingNext).toBe(true); + + // Data has not landed yet: an intermediate recheck must NOT drop the park + // (legacy: "keeps a pending advance across an intermediate render where isReachable is still unmet"). + actor.recheck(); + expect(actor.getSnapshot().value).toBe('verify-domain'); + expect(actor.getSnapshot().context.pendingNext).toBe(true); + + // The awaited revalidate finally lands. + actor.setContext({ allDomainsVerified: true }); + actor.recheck(); + expect(actor.getSnapshot().value).toBe('configure'); + expect(actor.getSnapshot().context.pendingNext).toBe(false); + }); +}); + +describe('organizationProfileSecurityWizardMachine — recheck clamp edge behaviors (1:1 with the legacy render-phase clamp)', () => { + it('does not yank the user forward when the current step still holds (a later guard opening is not an auto-advance)', () => { + // On configure with domains verified + a connection; the user has NOT pressed Next. + const actor = actorAt('configure', { allDomainsVerified: true, hasConnection: true }); + expect(actor.getSnapshot().value).toBe('configure'); + + // `test` becomes reachable (minimum config saved) — but recheck only clamps a + // broken step, it never advances a still-valid one (legacy: "does NOT move when an + // isReachable goes TRUE while the active step still holds"). + actor.setContext({ hasMinimumConfiguration: true }); + actor.recheck(); + expect(actor.getSnapshot().value).toBe('configure'); + }); + + it('is a provable one-shot: a second recheck with the same context does not re-seat or re-notify', () => { + const actor = actorAt('test', { allDomainsVerified: true, hasConnection: true, hasMinimumConfiguration: true }); + // The connection backing `test`/`configure` is deleted; `configure` survives on verified domains. + actor.setContext({ hasConnection: false, hasMinimumConfiguration: false }); + actor.recheck(); + expect(actor.getSnapshot().value).toBe('configure'); + + const settled = actor.getSnapshot(); + const seen: string[] = []; + actor.subscribe(s => seen.push(s.value)); + actor.recheck(); + expect(actor.getSnapshot()).toBe(settled); // same ref — no second re-seat + expect(seen).toEqual([]); // and no notify + }); +}); + +describe('isSecurityWizardStepComplete — position-independent completion (legacy isComplete predicates)', () => { + const ctx = (over: Partial): OrganizationProfileSecurityWizardContext => ({ + direction: 0, + hasNavigated: false, + pendingNext: false, + allDomainsVerified: false, + hasConnection: false, + hasMinimumConfiguration: false, + isActive: false, + hasSuccessfulTestRun: false, + ...over, + }); + + it('marks each step complete from its own predicate, regardless of the current step', () => { + const c = ctx({ allDomainsVerified: true, hasMinimumConfiguration: true }); + expect(isSecurityWizardStepComplete('verify-domain', c)).toBe(true); + expect(isSecurityWizardStepComplete('configure', c)).toBe(true); + expect(isSecurityWizardStepComplete('test', c)).toBe(false); + expect(isSecurityWizardStepComplete('activate', c)).toBe(false); + }); + + it('an active connection keeps configure/test/activate ticked (stays complete after a back-nav)', () => { + const c = ctx({ allDomainsVerified: true, isActive: true }); + expect(isSecurityWizardStepComplete('configure', c)).toBe(true); + expect(isSecurityWizardStepComplete('test', c)).toBe(true); + expect(isSecurityWizardStepComplete('activate', c)).toBe(true); + }); + + it('a successful test run completes test without an active connection', () => { + const c = ctx({ hasSuccessfulTestRun: true }); + expect(isSecurityWizardStepComplete('test', c)).toBe(true); + expect(isSecurityWizardStepComplete('activate', c)).toBe(false); + }); +}); 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..2f62b24198a --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-security-panel.controller.tsx @@ -0,0 +1,633 @@ +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, useRef, useState } from 'react'; + +import type { OrganizationEnterpriseConnection } from '@/components/ConfigureSSO/domain/organizationEnterpriseConnection'; +import { areAllOrganizationDomainsVerified } from '@/components/ConfigureSSO/domain/organizationEnterpriseConnection'; +import type { TestRunsView } from '@/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; +import { useOrganizationEnterpriseConnection } from '@/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; +import type { ProviderType } from '@/components/ConfigureSSO/types'; +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 { + OrganizationProfileSecurityWizardActivateContext, + OrganizationProfileSecurityWizardActivateEvent, +} from './organization-profile-security-wizard-activate.machine'; +import { organizationProfileSecurityWizardActivateMachine } from './organization-profile-security-wizard-activate.machine'; +import type { + OrganizationProfileSecurityWizardConfigureContext, + OrganizationProfileSecurityWizardConfigureEvent, +} from './organization-profile-security-wizard-configure.machine'; +import { + CONFIGURE_PROVIDER_STEPS, + organizationProfileSecurityWizardConfigureMachine, + SAML_SUBMIT_STEP_ID, +} from './organization-profile-security-wizard-configure.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'; +import type { + OrganizationProfileSecurityWizardTestContext, + OrganizationProfileSecurityWizardTestEvent, +} from './organization-profile-security-wizard-test.machine'; +import { organizationProfileSecurityWizardTestMachine } from './organization-profile-security-wizard-test.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 `configure` step's flow: the collapsed middle (`select-provider → configure-provider`) + + * inner per-provider SAML wizard. `providerSteps` / `submitStepId` are the per-provider inputs the + * view walks; `onParentNext` / `onParentPrev` forward the boundary navigations (terminal non-submit + * Continue, first Previous) to the outer wizard, which the machine cannot reach directly. + */ +export interface OrganizationProfileSecurityWizardConfigureStep { + snapshot: Snapshot; + send: (event: OrganizationProfileSecurityWizardConfigureEvent) => void; + /** The provider whose SAML sub-flow is being configured (undefined until a connection exists). */ + provider: ProviderType | undefined; + /** Ordered inner SAML step ids for `provider` (empty until a connection exists). */ + providerSteps: string[]; + /** The inner step id that submits `updateConnection`. */ + submitStepId: string; + /** Direction of entry into the step, so the view can force select-provider on a forward mount. */ + enteredForward: boolean; + /** Forward a boundary navigation to the outer wizard (no mutation involved). */ + onParentNext: () => void; + onParentPrev: () => void; +} + +/** + * The `test` step's flow: the two async actions (create-run, continue-validate) are the machine; + * the results table's rows / pagination / polling and the one-shot "Refresh logs" refetch are + * hook-owned data threaded straight through (`testRuns`). `refresh` here is the manual refetch that + * must NOT arm polling — only the machine's injected `createTestRun` arms it. + */ +export interface OrganizationProfileSecurityWizardTestStep { + snapshot: Snapshot; + send: (event: OrganizationProfileSecurityWizardTestEvent) => void; + /** The test-run list state the table renders + the one-shot (non-arming) `refresh` / pagination. */ + testRuns: TestRunsView; + /** Forward the footer Previous to the outer wizard. */ + onParentPrev: () => void; +} + +/** + * The `activate` step's flow: the terminal `setConnectionActive(id, true)` machine plus the derived + * display inputs. `isActive` selects the copy + Done-vs-Activate button; `domain` fills the subtitle; + * `onExit` is the Skip / Done exit (legacy `onExit()`) — a plain `exitWizard` with no mutation. + */ +export interface OrganizationProfileSecurityWizardActivateStep { + snapshot: Snapshot; + send: (event: OrganizationProfileSecurityWizardActivateEvent) => void; + /** Whether the connection is already active — selects the copy + Done-vs-Activate button. */ + isActive: boolean; + /** The connection's domain(s), joined for the subtitle copy. */ + domain: string; + /** Exit the wizard back to the overview (Skip / Done). */ + onExit: () => 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; + configureStep: OrganizationProfileSecurityWizardConfigureStep; + testStep: OrganizationProfileSecurityWizardTestStep; + activateStep: OrganizationProfileSecurityWizardActivateStep; + }; + +/** + * 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, + testRuns, + organizationDomains, + organizationDomainMutations, + } = useOrganizationEnterpriseConnection(); + const { + setConnectionActive, + deleteConnection, + updateConnection, + createConnection, + changeProvider, + createTestRun: createTestRunMutation, + } = enterpriseConnectionMutations; + const { createDomain, prepareOwnershipVerification, revalidate } = organizationDomainMutations; + const connectionStatus = organizationEnterpriseConnection.status; + const connectionId = enterpriseConnection?.id ?? null; + const organizationId = organization?.id ?? null; + const provider = organizationEnterpriseConnection.provider; + const providerSteps = provider ? CONFIGURE_PROVIDER_STEPS[provider] : []; + const submitIndex = providerSteps.indexOf(SAML_SUBMIT_STEP_ID); + + // 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); + } + }, + }, + }); + + // The `configure` step: the collapsed middle + inner SAML wizard. Injected mutations reproduce + // the legacy select-provider (`createConnection` / `changeProvider`, global error) and the SAML + // save (`updateConnection` with the `saml` payload, field-first error) 1:1. + const [configureSnapshot, sendConfigure, configureActor] = useMachine( + organizationProfileSecurityWizardConfigureMachine, + { + context: { + providerSteps, + submitIndex, + hasConnection, + createConnection: async selected => { + try { + await createConnection(selected); + } catch (err) { + throw toConnectionError(err); + } + }, + changeProvider: async selected => { + try { + await changeProvider(selected); + } catch (err) { + throw toConnectionError(err); + } + }, + updateConnection: async payload => { + if (!enterpriseConnection) { + return; + } + try { + await updateConnection(enterpriseConnection.id, { saml: payload }); + } catch (err) { + throw toDomainError(err); + } + }, + }, + }, + ); + + // Same live-data recheck as the outer wizard: a create/change advance parked on a stale + // `hasConnection` completes once the fresh value lands, and a footer Reset that deleted the + // connection re-seats `configuring` back to `selecting`. + useEffect(() => { + configureActor.recheck(); + // `provider` (stable) is the source of `providerSteps` / `submitIndex`; keying on it avoids the + // fresh-`[]`-every-render churn that depending on the derived array would cause. + }, [configureActor, hasConnection, provider, submitIndex]); + + // The terminal SAML save's bubble to the outer wizard. The configure machine cannot reach the + // outer actor, so it rests in `bubblingNext`; on the rising edge into that state the controller + // forwards a single outer `NEXT` (which the outer wizard defers to `test` via its own + // `pendingNext`). One-shot via the ref so a re-render never double-advances the outer wizard. + const bubbledRef = useRef(false); + useEffect(() => { + if (configureSnapshot.value === 'bubblingNext') { + if (!bubbledRef.current) { + bubbledRef.current = true; + sendWizard({ type: 'NEXT' }); + } + } else { + bubbledRef.current = false; + } + }, [configureSnapshot.value, sendWizard]); + + // The `test` step: the two async actions (create-run, continue-validate). The injected + // `createTestRun` reproduces the legacy side-effect chain 1:1 — create the run, reset to page 1, + // refetch the list with polling ARMED, then open the URL. Errors normalize to a plain message + // (legacy global `handleError`). `revalidateHasSuccessfulTestRun` is the one-shot success probe. + const [testSnapshot, sendTest, testActor] = useMachine(organizationProfileSecurityWizardTestMachine, { + context: { + hasSuccessfulTestRun, + noSuccessfulRunMessage: + 'You need at least one successful test run before you can continue. Generate a test URL and complete the sign-in flow.', + createTestRun: async () => { + if (!enterpriseConnection) { + return; + } + try { + const { url } = await createTestRunMutation(enterpriseConnection.id); + // Reset to the first page and refetch with polling ARMED so the freshly-created run + // surfaces on its own (legacy `handleTestRunCreated`), then open the IdP test URL. + testRuns.setPage(1); + void testRuns.refresh({ armPolling: true }); + // `noopener,noreferrer` so the IdP can't reach back into the dashboard via `window.opener`. + window.open(url, '_blank', 'noopener,noreferrer'); + } catch (err) { + throw toConnectionError(err); + } + }, + revalidateHasSuccessfulTestRun: async () => { + try { + return await testRuns.revalidateHasSuccessfulTestRun(); + } catch (err) { + throw toConnectionError(err); + } + }, + }, + }); + + // Keep the Continue fast-path gate current: when the success probe changes, re-settle so a parked + // advance completes (there is no entry guard on the test states, so this only re-runs `always` — + // harmless, and keeps the pattern uniform with the other step actors). + useEffect(() => { + testActor.recheck(); + }, [testActor, hasSuccessfulTestRun]); + + // Same terminal bubble as configure: a successful Continue rests in `bubblingNext`; on the rising + // edge the controller forwards a single outer `NEXT` (the outer wizard defers it to `activate` via + // its `pendingNext`). One-shot via the ref so a re-render never double-advances. + const testBubbledRef = useRef(false); + useEffect(() => { + if (testSnapshot.value === 'bubblingNext') { + if (!testBubbledRef.current) { + testBubbledRef.current = true; + sendWizard({ type: 'NEXT' }); + } + } else { + testBubbledRef.current = false; + } + }, [testSnapshot.value, sendWizard]); + + // The `activate` step: the terminal `setConnectionActive(id, true)`. Legacy records the activate + // telemetry AFTER the mutation resolves (with `connectionStatus: 'active'`), so it is fired here + // on success — inside the injected function, before the machine reaches `activated`. + const [activateSnapshot, sendActivate, activateActor] = useMachine(organizationProfileSecurityWizardActivateMachine, { + context: { + isActive, + activateConnection: async () => { + if (!enterpriseConnection) { + return; + } + try { + await setConnectionActive(enterpriseConnection.id, true); + } catch (err) { + throw toConnectionError(err); + } + clerk.telemetry?.record( + eventFlowStepMounted('configureSSO', 'activate', { + timestamp: new Date().toISOString(), + connectionStatus: 'active', + connectionId: enterpriseConnection.id, + organizationId, + }), + ); + }, + }, + }); + + useEffect(() => { + activateActor.recheck(); + }, [activateActor, isActive]); + + // The activate "bubble": legacy `onExit()` after a successful activate. On the rising edge into + // `activated` the controller returns the panel to the overview. One-shot via the ref. + const activatedRef = useRef(false); + useEffect(() => { + if (activateSnapshot.value === 'activated') { + if (!activatedRef.current) { + activatedRef.current = true; + setMode('overview'); + } + } else { + activatedRef.current = false; + } + }, [activateSnapshot.value]); + + // 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, + }, + configureStep: { + snapshot: configureSnapshot, + send: sendConfigure, + provider, + providerSteps, + submitStepId: SAML_SUBMIT_STEP_ID, + enteredForward: wizardSnapshot.context.direction === 1, + onParentNext: () => sendWizard({ type: 'NEXT' }), + onParentPrev: () => sendWizard({ type: 'PREV' }), + }, + testStep: { + snapshot: testSnapshot, + send: sendTest, + testRuns, + onParentPrev: () => sendWizard({ type: 'PREV' }), + }, + activateStep: { + snapshot: activateSnapshot, + send: sendActivate, + isActive, + domain: (enterpriseConnection?.domains ?? []).join(', '), + onExit: () => setMode('overview'), + }, + }; +} 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..25a2dd5cff5 --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-security-panel.tsx @@ -0,0 +1,34 @@ +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..f94b1c07854 --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-security-panel.view.tsx @@ -0,0 +1,508 @@ +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 { + OrganizationProfileSecurityWizardActivateStep, + OrganizationProfileSecurityWizardConfigureStep, + OrganizationProfileSecurityWizardDomainsStep, + OrganizationProfileSecurityWizardTestStep, +} 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 { OrganizationProfileSecurityWizardActivateView } from './organization-profile-security-wizard-activate.view'; +import { OrganizationProfileSecurityWizardConfigureView } from './organization-profile-security-wizard-configure.view'; +import { OrganizationProfileSecurityWizardDomainsView } from './organization-profile-security-wizard-domains.view'; +import { OrganizationProfileSecurityWizardTestView } from './organization-profile-security-wizard-test.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; + configureStep: OrganizationProfileSecurityWizardConfigureStep; + testStep: OrganizationProfileSecurityWizardTestStep; + activateStep: OrganizationProfileSecurityWizardActivateStep; +} + +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 ( +