Skip to content

Commit 0835847

Browse files
committed
feat(ui,clerk-js,shared,localizations): Offer enterprise SSO in the reverification modal
The modal dropped enterprise_sso from the supported first factors, so a user whose only factor is an identity provider got "no available methods". It now shows a button per enterprise connection, runs the IdP round trip in a popup that reports back through the Account Portal popup callback, and falls back to a full-page redirect when the popup is blocked. The prepare call sends the email address of the enterprise account tied to the chosen connection, which FAPI requires. Apps that cannot open a popup can pass `oauthFlow: 'redirect'` to `useReverification` or to the modal props. 'auto' follows the sign-in heuristic. The default stays 'popup'. Part of ORGS-1939
1 parent 3abc482 commit 0835847

13 files changed

Lines changed: 369 additions & 6 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@clerk/ui': minor
3+
'@clerk/clerk-js': patch
4+
'@clerk/shared': patch
5+
'@clerk/localizations': patch
6+
---
7+
8+
Offer enterprise SSO as a first factor in the reverification modal. Users with an `enterprise_sso` factor now get a button per enterprise connection, verify with their identity provider in a popup, and the action that asked for reverification resumes without a page reload. When the popup is blocked the modal falls back to a full-page redirect, and `oauthFlow: 'redirect'` on `useReverification` or the modal props opts into the redirect from the start. `session.prepareFirstFactorVerification` accepts `actionCompleteRedirectUrl` for the `enterprise_sso` strategy.

‎packages/clerk-js/src/core/resources/Session.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ export class Session extends BaseResource implements SessionResource {
280280
emailAddressId: factor.emailAddressId,
281281
enterpriseConnectionId: factor.enterpriseConnectionId,
282282
redirectUrl: factor.redirectUrl,
283+
actionCompleteRedirectUrl: factor.actionCompleteRedirectUrl,
283284
} as EnterpriseSSOConfig;
284285
break;
285286
default:

‎packages/localizations/src/en-US.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,7 @@ export const enUS: LocalizationResource = {
16411641
actionText: 'Don’t have any of these?',
16421642
blockButton__backupCode: 'Use a backup code',
16431643
blockButton__emailCode: 'Email code to {{identifier}}',
1644+
blockButton__enterpriseSSO: 'Continue with {{name}}',
16441645
blockButton__passkey: 'Use your passkey',
16451646
blockButton__password: 'Continue with your password',
16461647
blockButton__phoneCode: 'Send SMS code to {{identifier}}',
@@ -1664,6 +1665,10 @@ export const enUS: LocalizationResource = {
16641665
subtitle: 'Enter the code sent to your email to continue',
16651666
title: 'Verification required',
16661667
},
1668+
enterpriseSSO: {
1669+
subtitle: 'Continue with your enterprise account to confirm it’s you',
1670+
title: 'Verification required',
1671+
},
16671672
noAvailableMethods: {
16681673
message: 'Cannot proceed with verification. No suitable authentication factor is configured',
16691674
subtitle: 'An error occurred',

‎packages/shared/src/react/hooks/useReverification.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export type NeedsReverificationParameters = {
5858
* @interface
5959
*/
6060
export type UseReverificationOptions = {
61+
/**
62+
* Control whether enterprise SSO verification in the default UI uses a popup or a full-page redirect. `'auto'` follows the same heuristic as the sign-in component.
63+
*
64+
* @default `'popup'`
65+
*/
66+
oauthFlow?: 'auto' | 'redirect' | 'popup';
6167
/**
6268
* Handler for the reverification process. Opts out of using the default UI. Use this to build a custom UI.
6369
*
@@ -140,6 +146,7 @@ function createReverificationHandler(params: CreateReverificationHandlerParams)
140146
*/
141147
params.openUIComponent?.({
142148
level: level,
149+
oauthFlow: params.oauthFlow,
143150
afterVerification: complete,
144151
afterVerificationCancelled: cancel,
145152
});

‎packages/shared/src/types/clerk.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1979,6 +1979,13 @@ export type __internal_UserVerificationProps = RoutingOptions & {
19791979
*/
19801980
afterVerificationCancelled?: () => void;
19811981

1982+
/**
1983+
* Control whether enterprise SSO verification uses a popup or a full-page redirect. `'auto'` follows the same heuristic as the sign-in component.
1984+
*
1985+
* @default `'popup'`
1986+
*/
1987+
oauthFlow?: 'auto' | 'redirect' | 'popup';
1988+
19821989
/**
19831990
* Defines the steps of the verification flow.
19841991
* When `multiFactor` is used, the user will be prompt for a first factor flow followed by a second factor flow.

‎packages/shared/src/types/localization.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,10 @@ export type __internal_LocalizationResource = {
654654
subtitle: LocalizationValue;
655655
actionLink: LocalizationValue;
656656
};
657+
enterpriseSSO: {
658+
title: LocalizationValue;
659+
subtitle: LocalizationValue;
660+
};
657661
emailCode: {
658662
title: LocalizationValue;
659663
subtitle: LocalizationValue;
@@ -702,6 +706,7 @@ export type __internal_LocalizationResource = {
702706
blockButton__totp: LocalizationValue;
703707
blockButton__passkey: LocalizationValue;
704708
blockButton__backupCode: LocalizationValue;
709+
blockButton__enterpriseSSO: LocalizationValue<'name'>;
705710
getHelp: {
706711
title: LocalizationValue;
707712
content: LocalizationValue;

‎packages/shared/src/types/session.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,7 @@ export type SessionVerifyPrepareFirstFactorParams =
540540
/**
541541
* @experimental
542542
*/
543-
| Omit<EnterpriseSSOConfig, 'actionCompleteRedirectUrl'>
543+
| (Omit<EnterpriseSSOConfig, 'actionCompleteRedirectUrl'> & { actionCompleteRedirectUrl?: string })
544544
/**
545545
* @experimental
546546
*/

‎packages/ui/src/common/ChooseEnterpriseConnectionCard.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type ChooseEnterpriseConnectionCardProps = {
1515
subtitle: LocalizationKey;
1616
onClick: (id: string) => Promise<void>;
1717
enterpriseConnections: Array<{ id: string; name: string; logoPublicUrl?: string | null; provider?: string }>;
18+
showLogo?: boolean;
1819
};
1920

2021
/**
@@ -25,14 +26,15 @@ export const ChooseEnterpriseConnectionCard = ({
2526
subtitle,
2627
onClick,
2728
enterpriseConnections,
29+
showLogo = true,
2830
children,
2931
}: PropsWithChildren<ChooseEnterpriseConnectionCardProps>) => {
3032
const card = useCardState();
3133

3234
return (
3335
<Card.Root>
3436
<Card.Content>
35-
<Header.Root showLogo>
37+
<Header.Root showLogo={showLogo}>
3638
<Header.Title localizationKey={title} />
3739
<Header.Subtitle localizationKey={subtitle} />
3840
</Header.Root>

‎packages/ui/src/components/UserVerification/AlternativeMethods.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ export function getButtonLabel(factor: SessionVerificationFirstFactor): Localiza
118118
return localizationKeys('reverification.alternativeMethods.blockButton__password');
119119
case 'passkey':
120120
return localizationKeys('reverification.alternativeMethods.blockButton__passkey');
121+
case 'enterprise_sso':
122+
return localizationKeys('reverification.alternativeMethods.blockButton__enterpriseSSO', {
123+
name: factor.enterpriseConnectionName || '',
124+
});
121125
default:
122126
throw new Error(`Invalid sign in strategy: "${(factor as any).strategy}"`);
123127
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
2+
import { ClerkRuntimeError, isClerkRuntimeError } from '@clerk/shared/error';
3+
import { useClerk, useSession } from '@clerk/shared/react';
4+
import type { EnterpriseSSOFactor } from '@clerk/shared/types';
5+
import React from 'react';
6+
7+
import { ChooseEnterpriseConnectionCard } from '@/ui/common/ChooseEnterpriseConnectionCard';
8+
import { Card } from '@/ui/elements/Card';
9+
import { useCardState } from '@/ui/elements/contexts';
10+
import { handleError } from '@/ui/utils/errorHandler';
11+
import { originPrefersPopup } from '@/ui/utils/originPrefersPopup';
12+
13+
import { useUserVerification } from '../../contexts';
14+
import { Flow, localizationKeys } from '../../customizables';
15+
import { useRouter } from '../../router';
16+
import { useUserVerificationSession } from './useUserVerificationSession';
17+
18+
type UVFactorOneEnterpriseSSOCardProps = {
19+
factors: Array<EnterpriseSSOFactor & { enterpriseConnectionId: string; enterpriseConnectionName: string }>;
20+
onShowAlternativeMethodsClicked: React.MouseEventHandler | undefined;
21+
};
22+
23+
const POPUP_CLOSED_CODE = 'reverification_popup_closed';
24+
25+
const waitForPopupCallback = (popup: Window, url: URL, accountsOrigin: string) =>
26+
new Promise<void>((resolve, reject) => {
27+
let settled = false;
28+
const settle = (fn: () => void) => {
29+
if (settled) {
30+
return;
31+
}
32+
settled = true;
33+
window.removeEventListener('message', onMessage);
34+
clearInterval(interval);
35+
fn();
36+
};
37+
const onMessage = (event: MessageEvent) => {
38+
if (event.origin !== accountsOrigin) {
39+
return;
40+
}
41+
if (event.data?.session || event.data?.return_url) {
42+
settle(resolve);
43+
}
44+
};
45+
const interval = setInterval(() => {
46+
if (popup.closed) {
47+
clearInterval(interval);
48+
setTimeout(
49+
() =>
50+
settle(() => reject(new ClerkRuntimeError('Reverification popup was closed', { code: POPUP_CLOSED_CODE }))),
51+
1000,
52+
);
53+
}
54+
}, 500);
55+
56+
window.addEventListener('message', onMessage);
57+
popup.location.href = url.href;
58+
});
59+
60+
export const UVFactorOneEnterpriseSSOCard = (props: UVFactorOneEnterpriseSSOCardProps) => {
61+
const { factors, onShowAlternativeMethodsClicked } = props;
62+
const clerk = useClerk();
63+
const { session } = useSession();
64+
const { data } = useUserVerificationSession();
65+
const { afterVerification, oauthFlow } = useUserVerification();
66+
const { navigate } = useRouter();
67+
const card = useCardState();
68+
69+
const completeVerification = async (): Promise<void> => {
70+
if (data?.level === 'multi_factor') {
71+
await navigate('./factor-two');
72+
return;
73+
}
74+
await clerk.setActive({ session: session?.id });
75+
afterVerification?.();
76+
};
77+
78+
const emailAddressIdFor = (enterpriseConnectionId: string) => {
79+
const user = session?.user;
80+
const enterpriseEmail = user?.enterpriseAccounts.find(
81+
account => account.enterpriseConnection?.id === enterpriseConnectionId,
82+
)?.emailAddress;
83+
return (
84+
user?.emailAddresses.find(email => email.emailAddress === enterpriseEmail)?.id ??
85+
user?.primaryEmailAddressId ??
86+
undefined
87+
);
88+
};
89+
90+
const verifyWithEnterpriseSSO = async (enterpriseConnectionId: string, popup: Window | null): Promise<void> => {
91+
if (!session) {
92+
return;
93+
}
94+
95+
const accountsOrigin = buildAccountsBaseUrl(clerk.frontendApi);
96+
const popupCallbackUrl = `${accountsOrigin}/popup-callback`;
97+
const currentUrl = window.location.href;
98+
99+
const response = await session.prepareFirstFactorVerification({
100+
strategy: 'enterprise_sso',
101+
enterpriseConnectionId,
102+
emailAddressId: emailAddressIdFor(enterpriseConnectionId),
103+
redirectUrl: popup
104+
? clerk.buildUrlWithAuth(`${popupCallbackUrl}?return_url=${encodeURIComponent(currentUrl)}`)
105+
: currentUrl,
106+
actionCompleteRedirectUrl: popup ? clerk.buildUrlWithAuth(popupCallbackUrl) : currentUrl,
107+
});
108+
109+
const url = response.firstFactorVerification.externalVerificationRedirectURL;
110+
if (!url) {
111+
throw new ClerkRuntimeError('Missing external verification redirect URL', {
112+
code: 'external_verification_redirect_url_missing',
113+
});
114+
}
115+
116+
if (!popup) {
117+
await clerk.navigate(url.href);
118+
return;
119+
}
120+
121+
await waitForPopupCallback(popup, url, accountsOrigin);
122+
await completeVerification();
123+
};
124+
125+
const handleSelectEnterpriseConnection = (enterpriseConnectionId: string) => {
126+
const usePopup = oauthFlow === 'redirect' ? false : oauthFlow === 'auto' ? originPrefersPopup() : true;
127+
const popup = usePopup ? window.open('about:blank', '', 'width=600,height=800') : null;
128+
return verifyWithEnterpriseSSO(enterpriseConnectionId, popup).catch(err => {
129+
popup?.close();
130+
if (!isClerkRuntimeError(err) || err.code !== POPUP_CLOSED_CODE) {
131+
handleError(err, [], card.setError);
132+
}
133+
throw err;
134+
});
135+
};
136+
137+
return (
138+
<Flow.Part part='enterpriseConnections'>
139+
<ChooseEnterpriseConnectionCard
140+
title={localizationKeys('reverification.enterpriseSSO.title')}
141+
subtitle={localizationKeys('reverification.enterpriseSSO.subtitle')}
142+
onClick={handleSelectEnterpriseConnection}
143+
enterpriseConnections={factors.map(factor => ({
144+
id: factor.enterpriseConnectionId,
145+
name: factor.enterpriseConnectionName,
146+
logoPublicUrl: factor.enterpriseConnectionLogoPublicUrl,
147+
provider: factor.enterpriseConnectionProvider,
148+
}))}
149+
showLogo={false}
150+
>
151+
{onShowAlternativeMethodsClicked && (
152+
<Card.Action elementId='alternativeMethods'>
153+
<Card.ActionLink
154+
localizationKey={localizationKeys('footerActionLink__useAnotherMethod')}
155+
onClick={onShowAlternativeMethodsClicked}
156+
/>
157+
</Card.Action>
158+
)}
159+
</ChooseEnterpriseConnectionCard>
160+
</Flow.Part>
161+
);
162+
};

0 commit comments

Comments
 (0)