') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(ui,shared,localizations): Allow changing provider in self-serve SSO by LauraBeatris · Pull Request #8881 · clerk/javascript · GitHub
Skip to content
Merged
8 changes: 8 additions & 0 deletions .changeset/nine-lies-eat.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
'@clerk/localizations': patch
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/ui': patch
---

Allow changing enterprise connection provider between self-serve SSO steps
175 changes: 85 additions & 90 deletions packages/localizations/src/en-US.ts

Large diffs are not rendered by default.

33 changes: 16 additions & 17 deletions packages/shared/src/types/localization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1370,6 +1370,12 @@ export type __internal_LocalizationResource = {
};
warning: LocalizationValue;
};
changeProviderDialog: {
title: LocalizationValue<'provider'>;
subtitle: LocalizationValue<'provider' | 'currentProvider'>;
cancelButton: LocalizationValue;
confirmButton: LocalizationValue;
};
organizationDomainsStep: {
title: LocalizationValue;
subtitle: LocalizationValue;
Expand DownExpand Up@@ -1472,6 +1478,10 @@ export type __internal_LocalizationResource = {
optional: LocalizationValue;
};
};
activeConnectionWarning: {
title: LocalizationValue;
dismiss: LocalizationValue;
};
samlOkta: {
mainHeaderTitle: LocalizationValue;
createAppStep: {
Expand All@@ -1482,7 +1492,6 @@ export type __internal_LocalizationResource = {
step2: LocalizationValue;
step3: LocalizationValue;
step4: LocalizationValue;
step5: LocalizationValue;
};
serviceProviderInstructions: {
title: LocalizationValue;
Expand DownExpand Up@@ -1523,7 +1532,6 @@ export type __internal_LocalizationResource = {
assignUsersStep: {
headerSubtitle: LocalizationValue;
assignUsersInstructions: {
title: LocalizationValue;
paragraph: LocalizationValue;
step1: LocalizationValue;
step2: LocalizationValue;
Expand All@@ -1535,7 +1543,6 @@ export type __internal_LocalizationResource = {
identityProviderMetadataStep: {
headerSubtitle: LocalizationValue;
modes: {
title: LocalizationValue;
ariaLabel: LocalizationValue;
metadataUrl: LocalizationValue;
manual: LocalizationValue;
Expand DownExpand Up@@ -1570,7 +1577,6 @@ export type __internal_LocalizationResource = {
createAppStep: {
headerSubtitle: LocalizationValue;
createAppInstructions: {
title: LocalizationValue;
paragraph: LocalizationValue;
};
serviceProviderFields: {
Expand All@@ -1588,25 +1594,23 @@ export type __internal_LocalizationResource = {
attributeMappingTable: {
title: LocalizationValue;
columns: {
userProfile: LocalizationValue;
attributeName: LocalizationValue;
userAttribute: LocalizationValue;
};
rows: {
email: { userProfile: LocalizationValue; attributeName: LocalizationValue };
firstName: { userProfile: LocalizationValue; attributeName: LocalizationValue };
lastName: { userProfile: LocalizationValue; attributeName: LocalizationValue };
email: { attributeName: LocalizationValue; userAttribute: LocalizationValue };
firstName: { attributeName: LocalizationValue; userAttribute: LocalizationValue };
lastName: { attributeName: LocalizationValue; userAttribute: LocalizationValue };
};
};
};
assignUsersStep: {
headerSubtitle: LocalizationValue;
title: LocalizationValue;
paragraph: LocalizationValue;
};
identityProviderMetadataStep: {
headerSubtitle: LocalizationValue;
modes: {
title: LocalizationValue;
ariaLabel: LocalizationValue;
metadataUrl: LocalizationValue;
manual: LocalizationValue;
Expand DownExpand Up@@ -1646,13 +1650,11 @@ export type __internal_LocalizationResource = {
step2: LocalizationValue;
step3: LocalizationValue;
step4: LocalizationValue;
step5: LocalizationValue;
};
};
identityProviderMetadataStep: {
headerSubtitle: LocalizationValue;
modes: {
title: LocalizationValue;
ariaLabel: LocalizationValue;
metadataFile: LocalizationValue;
manual: LocalizationValue;
Expand DownExpand Up@@ -1749,13 +1751,11 @@ export type __internal_LocalizationResource = {
};
assignUsersInstructions: {
title: LocalizationValue;
paragraph1: LocalizationValue;
step1: LocalizationValue;
step2: LocalizationValue;
step3: LocalizationValue;
step4: LocalizationValue;
step5: LocalizationValue;
step6: LocalizationValue;
};
};
serviceProviderStep: {
Expand All@@ -1779,7 +1779,6 @@ export type __internal_LocalizationResource = {
identityProviderMetadataStep: {
headerSubtitle: LocalizationValue;
modes: {
title: LocalizationValue;
ariaLabel: LocalizationValue;
metadataUrl: LocalizationValue;
manual: LocalizationValue;
Expand DownExpand Up@@ -1811,16 +1810,16 @@ export type __internal_LocalizationResource = {
attributeMappingStep: {
headerSubtitle: LocalizationValue;
title: LocalizationValue;
paragraph: LocalizationValue;
step1: LocalizationValue;
step2: LocalizationValue;
step3: LocalizationValue;
attributeMappingTable: {
columns: {
attribute: LocalizationValue;
claimName: LocalizationValue;
value: LocalizationValue;
};
copyClaimName: LocalizationValue;
copyClaimNameCopied: LocalizationValue;
rows: {
email: { attribute: LocalizationValue; claimName: LocalizationValue; value: LocalizationValue };
firstName: { attribute: LocalizationValue; claimName: LocalizationValue; value: LocalizationValue };
Expand Down
97 changes: 97 additions & 0 deletions packages/ui/src/components/ConfigureSSO/ChangeProviderDialog.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
import type { LocalizationKey } from '@/customizables';
import { Button, Col, descriptors, Flex, Heading, localizationKeys, Text, useLocalizations } from '@/customizables';
import { Card } from '@/elements/Card';
import { withCardStateProvider } from '@/elements/contexts';
import { Modal } from '@/elements/Modal';

type ChangeProviderDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
isSubmitting?: boolean;
nextProviderLabel: LocalizationKey;
currentProviderLabel: LocalizationKey;
contentRef: React.RefObject<HTMLDivElement>;
};

export const ChangeProviderDialog = (props: ChangeProviderDialogProps): JSX.Element | null => {
if (!props.isOpen) {
return null;
}

return (
<Modal
handleClose={props.onClose}
canCloseModal={false}
portalRoot={props.contentRef}
containerSx={t => ({
alignItems: 'center',
position: 'absolute',
inset: 0,
width: 'auto',
height: 'auto',
backgroundColor: 'inherit',
backdropFilter: `blur(${t.sizes.$2})`,
})}
>
<ChangeProviderDialogContent {...props} />
</Modal>
);
};

const ChangeProviderDialogContent = withCardStateProvider((props: ChangeProviderDialogProps) => {
const { onClose, onConfirm, isSubmitting, nextProviderLabel, currentProviderLabel } = props;
const { t } = useLocalizations();

const nextProvider = t(nextProviderLabel);
const currentProvider = t(currentProviderLabel);

return (
<Card.Root
elementDescriptor={descriptors.configureSSOChangeProviderDialog}
sx={t => ({ borderRadius: t.radii.$md })}
>
<Card.Content sx={t => ({ textAlign: 'start', padding: t.sizes.$5 })}>
<Col sx={t => ({ gap: t.space.$4 })}>
<Col sx={t => ({ gap: t.space.$2 })}>
<Heading
textVariant='h2'
localizationKey={localizationKeys('configureSSO.changeProviderDialog.title', {
provider: nextProvider,
})}
sx={t => ({ fontSize: t.fontSizes.$md })}
/>
<Text
as='p'
colorScheme='secondary'
localizationKey={localizationKeys('configureSSO.changeProviderDialog.subtitle', {
provider: nextProvider,
currentProvider,
})}
/>
</Col>

<Flex
justify='end'
sx={t => ({ gap: t.space.$3 })}
>
<Button
elementDescriptor={descriptors.configureSSOChangeProviderDialogCancelButton}
variant='ghost'
isDisabled={isSubmitting}
onClick={onClose}
localizationKey={localizationKeys('configureSSO.changeProviderDialog.cancelButton')}
/>
<Button
elementDescriptor={descriptors.configureSSOChangeProviderDialogConfirmButton}
variant='solid'
isLoading={isSubmitting}
onClick={onConfirm}
localizationKey={localizationKeys('configureSSO.changeProviderDialog.confirmButton')}
/>
</Flex>
</Col>
</Card.Content>
</Card.Root>
);
});
20 changes: 5 additions & 15 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,13 +6,7 @@ import { ConfigureSSOProvider } from './ConfigureSSOContext';
import { ConfigureSSOHeader } from './ConfigureSSOHeader';
import { areAllOrganizationDomainsVerified } from './domain/organizationEnterpriseConnection';
import { Wizard, type WizardStepConfig } from './elements/Wizard';
import {
ActivateStep,
ConfigureStep,
OrganizationDomainsStep,
SelectProviderStep,
TestConfigurationStep,
} from './steps';
import { ActivateStep, ConfigureStep, OrganizationDomainsStep, TestConfigurationStep } from './steps';

export type ConfigureSSOWizardProps = Omit<ComponentProps<typeof ConfigureSSOProvider>, 'children'> & {
title?: React.ReactNode;
Expand All@@ -27,8 +21,10 @@ export const ConfigureSSOWizard = ({ title, forceInitialStep, ...props }: Config
const steps = React.useMemo<WizardStepConfig[]>(
() => [
{ id: 'verify-domain', label: 'Domains' },
{ id: 'select-provider', guard: () => allDomainsVerified },
{ id: 'configure', label: 'Connection', guard: () => c.hasConnection },
// `select-provider` now lives inside `configure` as its first sub-step, so
// reaching `configure` only requires verified domains (fresh start) or an
// existing connection (resume / change-provider).
{ id: 'configure', label: 'Connection', guard: () => allDomainsVerified || c.hasConnection },
{ id: 'test', label: 'Test', guard: () => c.hasMinimumConfiguration || c.isActive },
{ id: 'activate', label: 'Activate', guard: () => c.hasSuccessfulTestRun || c.isActive },
],
Expand All@@ -51,12 +47,6 @@ export const ConfigureSSOWizard = ({ title, forceInitialStep, ...props }: Config
</CardStateProvider>
</Wizard.Match>

<Wizard.Match id='select-provider'>
<CardStateProvider>
<SelectProviderStep />
</CardStateProvider>
</Wizard.Match>

<Wizard.Match id='configure'>
<CardStateProvider>
<ConfigureStep />
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,6 +107,22 @@ describe('ConfigureSSO wizard navigation (integration)', () => {
});
});

it('resumes the provider configuration when entering configure with an existing in-progress connection', async () => {
const { wrapper, fixtures } = await createFixtures(withAdminOrgUser);

fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([unconfiguredConnection] as any);
fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({
data: [],
total_count: 0,
} as any);
mockVerifiedDomains(fixtures);

const { findByRole, queryByText } = render(<ConfigureSSO />, { wrapper });

await findByRole('heading', { name: /configure okta workforce/i });
expect(queryByText(/select your identity provider/i)).not.toBeInTheDocument();
});

// Contract rules 7 + 10: reset deletes the connection, then the wizard
// re-derives to the furthest-reachable step for the now-no-connection state
// (select-provider, since the email is verified) and renders a real step body —
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,11 @@ export interface EnterpriseConnectionMutations {
* never thread them through.
*/
createConnection: (provider: ProviderType) => Promise<EnterpriseConnectionResource | undefined>;
/**
* Swaps the active organization's connection to a different provider. This removes the existing
* connection and creates a fresh one.
*/
changeProvider: (provider: ProviderType) => Promise<EnterpriseConnectionResource | undefined>;
updateConnection: (
id: string,
params: UpdateOrganizationEnterpriseConnectionParams,
Expand DownExpand Up@@ -222,6 +227,21 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise
});
};

const changeProvider: EnterpriseConnectionMutations['changeProvider'] = async provider => {
// Currently it's not possible to change the provider of an existing connection,
// so we need to delete the existing connection and create a new one.
if (enterpriseConnection) {
await deleteEnterpriseConnection(enterpriseConnection.id);
}

const domains = enterpriseConnection?.domains ?? organizationDomains?.map(domain => domain.name);

return createEnterpriseConnection({
provider,
domains,
});
Comment thread
LauraBeatris marked this conversation as resolved.
};

const updateConnection: EnterpriseConnectionMutations['updateConnection'] = (id, params) =>
updateEnterpriseConnection(id, params);

Expand All@@ -244,6 +264,7 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise

return {
createConnection,
changeProvider,
updateConnection,
setConnectionActive,
deleteConnection,
Expand All@@ -253,6 +274,7 @@ export const useOrganizationEnterpriseConnection = (): UseOrganizationEnterprise
user,
organization,
organizationDomains,
enterpriseConnection,
createEnterpriseConnection,
updateEnterpriseConnection,
deleteEnterpriseConnection,
Expand Down
Loading
Loading