Skip to content

Commit 4dd97bd

Browse files
authored
feat(ui): Introduce dialog to reset enterprise connection (#8706)
1 parent 8d6bb56 commit 4dd97bd

15 files changed

Lines changed: 510 additions & 217 deletions

File tree

.changeset/cool-boats-burn.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@clerk/ui': patch
3+
---
4+
5+
Reworks the `<ConfigureSSO />` confirmation step and adds a dedicated reset connection dialog:
6+
7+
- Introduces `<ResetConnectionDialog />` — a modal-based, type-to-confirm dialog scoped to the wizard container that replaces the inline reset confirmation card. Wraps the destructive delete behind `useReverification`, clears the local provider selection, and rewinds the wizard to provider selection on success.
8+
- Restyles the confirmation step body: unified status header with an inline `Active` / `Inactive` badge, grouped Enable SSO and Domain rows, two-column configuration details rendered through `ProfileSection.ItemList`, outlined `Configure again`, destructive `Reset connection`, and an inactive-state banner inside the step footer.
9+
- `Step.Header` now accepts a `badge` prop so a step can render an inline status pill next to its title without crowding the right-aligned children slot.
10+
- `OrganizationProfile` forwards the shared content ref to `<ConfigureSSO />` so the new dialog portals into the wizard chrome when the component is embedded inside the organization profile.

packages/localizations/src/en-US.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,10 @@ export const enUS: LocalizationResource = {
225225
statusSection: {
226226
activeBadge: 'Active',
227227
inactiveBadge: 'Inactive',
228-
title: 'SSO Status',
228+
title: 'SSO Successfully configured',
229+
},
230+
inactiveBanner: {
231+
title: 'SSO is inactive and you need to enable it to authenticate',
229232
},
230233
},
231234
missingManageEnterpriseConnectionsPermission: {
@@ -235,6 +238,15 @@ export const enUS: LocalizationResource = {
235238
navbar: {
236239
title: 'Configure Single Sign-On (SSO)',
237240
},
241+
resetConnectionDialog: {
242+
cancelButton: 'Cancel',
243+
confirmationFieldLabel: 'Type "{{name}}" below to continue',
244+
confirmationFieldPlaceholder: '{{name}}',
245+
resetButton: 'Reset connection',
246+
subtitle:
247+
'Are you sure you want to reset the connection? This action is irreversible and you will have to configure all steps again',
248+
title: 'Reset connection',
249+
},
238250
selectProviderStep: {
239251
title: 'Select your identity provider',
240252
subtitle: 'We’ll guide you through the detailed setup process next.',

packages/shared/src/types/localization.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,14 @@ export type __internal_LocalizationResource = {
13011301
navbar: {
13021302
title: LocalizationValue;
13031303
};
1304+
resetConnectionDialog: {
1305+
cancelButton: LocalizationValue;
1306+
confirmationFieldLabel: LocalizationValue<'name'>;
1307+
confirmationFieldPlaceholder: LocalizationValue<'name'>;
1308+
resetButton: LocalizationValue;
1309+
subtitle: LocalizationValue;
1310+
title: LocalizationValue;
1311+
};
13041312
selectProviderStep: {
13051313
title: LocalizationValue;
13061314
subtitle: LocalizationValue;
@@ -1791,6 +1799,9 @@ export type __internal_LocalizationResource = {
17911799
confirmationFieldLabel: LocalizationValue<'name'>;
17921800
submitButton: LocalizationValue;
17931801
};
1802+
inactiveBanner: {
1803+
title: LocalizationValue;
1804+
};
17941805
};
17951806
};
17961807
apiKeys: {

packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,7 @@ export interface ConfigureSSOData {
2828
* made on the Select Provider step.
2929
*/
3030
provider: ProviderType | undefined;
31-
/**
32-
* Sets the local provider selection used by Select Provider before a
33-
* connection has been created.
34-
*/
35-
setProvider: (provider: ProviderType) => void;
31+
setProvider: (provider: ProviderType | undefined) => void;
3632
/**
3733
* Ref to the scrollable content container of the wizard.
3834
*/
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { useReverification } from '@clerk/shared/react';
2+
3+
import { Col, descriptors, localizationKeys } from '@/customizables';
4+
import { Card } from '@/elements/Card';
5+
import { useCardState, withCardStateProvider } from '@/elements/contexts';
6+
import { Form } from '@/elements/Form';
7+
import { FormButtonContainer } from '@/elements/FormButtons';
8+
import { FormContainer } from '@/elements/FormContainer';
9+
import { Modal } from '@/elements/Modal';
10+
import { useFormControl } from '@/ui/utils/useFormControl';
11+
import { handleError } from '@/utils/errorHandler';
12+
13+
import { useConfigureSSO } from './ConfigureSSOContext';
14+
import { useWizard } from './elements/Wizard';
15+
16+
type ResetConnectionDialogProps = {
17+
isOpen: boolean;
18+
onClose: () => void;
19+
confirmationValue: string;
20+
};
21+
22+
export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.Element | null => {
23+
const { contentRef } = useConfigureSSO();
24+
25+
if (!props.isOpen) {
26+
return null;
27+
}
28+
29+
return (
30+
<Modal
31+
handleClose={props.onClose}
32+
canCloseModal={false}
33+
portalRoot={contentRef}
34+
containerSx={t => ({
35+
alignItems: 'center',
36+
position: 'absolute',
37+
inset: 0,
38+
width: 'auto',
39+
height: 'auto',
40+
backgroundColor: 'inherit',
41+
backdropFilter: `blur(${t.sizes.$2})`,
42+
})}
43+
>
44+
<ResetConnectionDialogContent {...props} />
45+
</Modal>
46+
);
47+
};
48+
49+
const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnectionDialogProps) => {
50+
const { onClose, confirmationValue } = props;
51+
const card = useCardState();
52+
const { enterpriseConnection, deleteEnterpriseConnection, setProvider } = useConfigureSSO();
53+
const { goToStep } = useWizard();
54+
55+
const deleteConnection = useReverification((id: string) => deleteEnterpriseConnection(id));
56+
57+
const confirmationField = useFormControl('deleteConfirmation', '', {
58+
type: 'text',
59+
label: localizationKeys('configureSSO.resetConnectionDialog.confirmationFieldLabel', {
60+
name: confirmationValue,
61+
}),
62+
isRequired: true,
63+
placeholder: confirmationValue,
64+
});
65+
66+
const canSubmit = Boolean(confirmationValue && confirmationField.value === confirmationValue);
67+
68+
const onSubmit = async () => {
69+
if (!enterpriseConnection || !canSubmit) {
70+
return;
71+
}
72+
73+
try {
74+
await deleteConnection(enterpriseConnection.id);
75+
setProvider(undefined);
76+
await goToStep('select-provider');
77+
onClose();
78+
} catch (err) {
79+
handleError(err as Error, [confirmationField], card.setError);
80+
}
81+
};
82+
83+
return (
84+
<Card.Root
85+
elementDescriptor={descriptors.configureSSOResetConnectionDialog}
86+
sx={t => ({ borderRadius: t.radii.$md })}
87+
>
88+
<Card.Content sx={t => ({ textAlign: 'start', padding: t.sizes.$5 })}>
89+
<FormContainer
90+
headerTitle={localizationKeys('configureSSO.resetConnectionDialog.title')}
91+
headerSubtitle={localizationKeys('configureSSO.resetConnectionDialog.subtitle')}
92+
sx={t => ({ gap: t.space.$4 })}
93+
>
94+
<Form.Root onSubmit={onSubmit}>
95+
<Col gap={4}>
96+
<Form.ControlRow elementId={confirmationField.id}>
97+
<Form.PlainInput
98+
{...confirmationField.props}
99+
elementDescriptor={descriptors.configureSSOResetConnectionDialogConfirmationInput}
100+
ignorePasswordManager
101+
/>
102+
</Form.ControlRow>
103+
<FormButtonContainer>
104+
<Form.SubmitButton
105+
elementDescriptor={descriptors.configureSSOResetConnectionDialogSubmitButton}
106+
block={false}
107+
colorScheme='danger'
108+
isDisabled={!canSubmit}
109+
localizationKey={localizationKeys('configureSSO.resetConnectionDialog.resetButton')}
110+
/>
111+
<Form.ResetButton
112+
elementDescriptor={descriptors.configureSSOResetConnectionDialogCancelButton}
113+
block={false}
114+
localizationKey={localizationKeys('configureSSO.resetConnectionDialog.cancelButton')}
115+
onClick={onClose}
116+
/>
117+
</FormButtonContainer>
118+
</Col>
119+
</Form.Root>
120+
</FormContainer>
121+
</Card.Content>
122+
</Card.Root>
123+
);
124+
});
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import type { DeletedObjectResource, EnterpriseConnectionResource } from '@clerk/shared/types';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { bindCreateFixtures } from '@/test/create-fixtures';
5+
import { render, screen, waitFor } from '@/test/utils';
6+
import { CardStateProvider } from '@/ui/elements/contexts';
7+
8+
const goToStep = vi.fn();
9+
10+
vi.mock('../elements/Wizard', () => ({
11+
useWizard: () => ({
12+
activeSteps: [],
13+
currentStep: undefined,
14+
currentIndex: -1,
15+
totalSteps: 0,
16+
isFirstStep: true,
17+
isLastStep: false,
18+
isNested: false,
19+
goNext: vi.fn(),
20+
goPrev: vi.fn(),
21+
goToStep,
22+
registerStep: vi.fn(),
23+
unregisterStep: vi.fn(),
24+
}),
25+
}));
26+
27+
const setProvider = vi.fn();
28+
const deleteEnterpriseConnection = vi.fn();
29+
30+
const connectionMockState = vi.hoisted(() => ({
31+
current: { id: 'idn_connection_1' } as Partial<EnterpriseConnectionResource> | null,
32+
}));
33+
34+
vi.mock('../ConfigureSSOContext', () => ({
35+
useConfigureSSO: () => ({
36+
enterpriseConnection: connectionMockState.current,
37+
provider: undefined,
38+
setProvider,
39+
deleteEnterpriseConnection,
40+
initialStepId: 'confirmation',
41+
contentRef: { current: null },
42+
createEnterpriseConnection: vi.fn(),
43+
updateEnterpriseConnection: vi.fn(),
44+
isDomainTakenByOtherOrg: false,
45+
}),
46+
}));
47+
48+
import { ResetConnectionDialog } from '../ResetConnectionDialog';
49+
50+
const { createFixtures } = bindCreateFixtures('ConfigureSSO');
51+
52+
const renderDialog = (
53+
wrapper: React.ComponentType<{ children?: React.ReactNode }>,
54+
props: { isOpen?: boolean; onClose?: () => void; confirmationValue?: string } = {},
55+
) => {
56+
const onClose = props.onClose ?? vi.fn();
57+
const utils = render(
58+
<CardStateProvider>
59+
<ResetConnectionDialog
60+
isOpen={props.isOpen ?? true}
61+
onClose={onClose}
62+
confirmationValue={props.confirmationValue ?? 'Acme Inc'}
63+
/>
64+
</CardStateProvider>,
65+
{ wrapper },
66+
);
67+
return { ...utils, onClose };
68+
};
69+
70+
const resetMocks = () => {
71+
goToStep.mockReset();
72+
setProvider.mockReset();
73+
deleteEnterpriseConnection.mockReset();
74+
deleteEnterpriseConnection.mockResolvedValue({} as DeletedObjectResource);
75+
goToStep.mockResolvedValue(undefined);
76+
connectionMockState.current = { id: 'idn_connection_1' };
77+
};
78+
79+
describe('ResetConnectionDialog', () => {
80+
it('does not render when `isOpen` is `false`', async () => {
81+
resetMocks();
82+
const { wrapper } = await createFixtures();
83+
renderDialog(wrapper, { isOpen: false });
84+
85+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
86+
expect(screen.queryByRole('heading', { name: 'Reset connection' })).not.toBeInTheDocument();
87+
});
88+
89+
it('renders the dialog chrome and actions when isOpen is true', async () => {
90+
resetMocks();
91+
const { wrapper } = await createFixtures();
92+
renderDialog(wrapper, { confirmationValue: 'Acme Inc' });
93+
94+
expect(screen.getByRole('dialog')).toBeInTheDocument();
95+
expect(screen.getByRole('heading', { name: 'Reset connection' })).toBeInTheDocument();
96+
expect(
97+
screen.getByText(
98+
/Are you sure you want to reset the connection\? This action is irreversible and you will have to configure all steps again/i,
99+
),
100+
).toBeInTheDocument();
101+
expect(screen.getByRole('button', { name: 'Reset connection' })).toBeInTheDocument();
102+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
103+
});
104+
105+
it('keeps Reset disabled while the input is empty', async () => {
106+
resetMocks();
107+
const { wrapper } = await createFixtures();
108+
renderDialog(wrapper, { confirmationValue: 'Acme Inc' });
109+
110+
expect(screen.getByRole('button', { name: 'Reset connection' })).toBeDisabled();
111+
});
112+
113+
it('keeps Reset disabled when the input does not match the confirmation value', async () => {
114+
resetMocks();
115+
const { wrapper } = await createFixtures();
116+
const { userEvent } = renderDialog(wrapper, { confirmationValue: 'Acme Inc' });
117+
118+
await userEvent.type(screen.getByLabelText(/below to continue/i), 'wrong');
119+
expect(screen.getByRole('button', { name: 'Reset connection' })).toBeDisabled();
120+
});
121+
122+
it('enables Reset when the input matches the confirmation value exactly', async () => {
123+
resetMocks();
124+
const { wrapper } = await createFixtures();
125+
const { userEvent } = renderDialog(wrapper, { confirmationValue: 'Acme Inc' });
126+
127+
await userEvent.type(screen.getByLabelText(/below to continue/i), 'Acme Inc');
128+
await waitFor(() => {
129+
expect(screen.getByRole('button', { name: 'Reset connection' })).toBeEnabled();
130+
});
131+
});
132+
133+
it('invokes onClose when Cancel is clicked', async () => {
134+
resetMocks();
135+
const onClose = vi.fn();
136+
const { wrapper } = await createFixtures();
137+
const { userEvent } = renderDialog(wrapper, { onClose });
138+
139+
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
140+
expect(onClose).toHaveBeenCalledTimes(1);
141+
expect(deleteEnterpriseConnection).not.toHaveBeenCalled();
142+
});
143+
144+
it('deletes the connection, clears the provider, rewinds the wizard, and closes on a successful submit', async () => {
145+
resetMocks();
146+
const onClose = vi.fn();
147+
const { wrapper } = await createFixtures();
148+
const { userEvent } = renderDialog(wrapper, { confirmationValue: 'Acme Inc', onClose });
149+
150+
await userEvent.type(screen.getByLabelText(/below to continue/i), 'Acme Inc');
151+
await userEvent.click(screen.getByRole('button', { name: 'Reset connection' }));
152+
153+
await waitFor(() => {
154+
expect(deleteEnterpriseConnection).toHaveBeenCalledWith('idn_connection_1');
155+
});
156+
expect(setProvider).toHaveBeenCalledWith(undefined);
157+
expect(goToStep).toHaveBeenCalledWith('select-provider');
158+
await waitFor(() => {
159+
expect(onClose).toHaveBeenCalledTimes(1);
160+
});
161+
});
162+
});

0 commit comments

Comments
 (0)