Skip to content

Commit a66357e

Browse files
authored
feat(clerk-js,types,localizations): Choose enterprise connection on sign-in/sign-up (#6947)
1 parent 4a1d748 commit a66357e

File tree

22 files changed

+441
-16
lines changed

22 files changed

+441
-16
lines changed

.changeset/upset-results-win.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/localizations': minor
3+
'@clerk/clerk-js': minor
4+
'@clerk/types': minor
5+
---
6+
7+
Introduce experimental step to choose enterprise connection on sign-in/sign-up

.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
127127
"types/sign-in-signal-value.mdx",
128128
"types/sign-out.mdx",
129129
"types/sign-up-authenticate-with-metamask-params.mdx",
130+
"types/sign-up-enterprise-connection-json.mdx",
131+
"types/sign-up-enterprise-connection-resource.mdx",
130132
"types/sign-up-future-resource.mdx",
131133
"types/sign-up-resource.mdx",
132134
"types/signed-in-session-resource.mdx",

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
{ "path": "./dist/organizationswitcher*.js", "maxSize": "5KB" },
1717
{ "path": "./dist/organizationlist*.js", "maxSize": "5.5KB" },
1818
{ "path": "./dist/signin*.js", "maxSize": "18KB" },
19-
{ "path": "./dist/signup*.js", "maxSize": "8.86KB" },
19+
{ "path": "./dist/signup*.js", "maxSize": "9.5KB" },
2020
{ "path": "./dist/userbutton*.js", "maxSize": "5KB" },
2121
{ "path": "./dist/userprofile*.js", "maxSize": "16KB" },
2222
{ "path": "./dist/userverification*.js", "maxSize": "5KB" },

packages/clerk-js/src/core/resources/SignIn.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ export class SignIn extends BaseResource implements SignInResource {
222222
redirectUrl: params.redirectUrl,
223223
actionCompleteRedirectUrl: params.actionCompleteRedirectUrl,
224224
oidcPrompt: params.oidcPrompt,
225+
enterpriseConnectionId: params.enterpriseConnectionId,
225226
} as EnterpriseSSOConfig;
226227
break;
227228
default:
@@ -308,7 +309,8 @@ export class SignIn extends BaseResource implements SignInResource {
308309
params: AuthenticateWithRedirectParams,
309310
navigateCallback: (url: URL | string) => void,
310311
): Promise<void> => {
311-
const { strategy, redirectUrlComplete, identifier, oidcPrompt, continueSignIn } = params || {};
312+
const { strategy, redirectUrlComplete, identifier, oidcPrompt, continueSignIn, enterpriseConnectionId } =
313+
params || {};
312314
const actionCompleteRedirectUrl = redirectUrlComplete;
313315

314316
const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl);
@@ -328,6 +330,7 @@ export class SignIn extends BaseResource implements SignInResource {
328330
redirectUrl,
329331
actionCompleteRedirectUrl,
330332
oidcPrompt,
333+
enterpriseConnectionId,
331334
});
332335
}
333336

packages/clerk-js/src/core/resources/SignUp.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import type {
1616
PrepareWeb3WalletVerificationParams,
1717
SignUpAuthenticateWithWeb3Params,
1818
SignUpCreateParams,
19+
SignUpEnterpriseConnectionJSON,
20+
SignUpEnterpriseConnectionResource,
1921
SignUpField,
2022
SignUpFutureCreateParams,
2123
SignUpFutureEmailCodeVerifyParams,
@@ -385,6 +387,7 @@ export class SignUp extends BaseResource implements SignUpResource {
385387
emailAddress,
386388
legalAccepted,
387389
oidcPrompt,
390+
enterpriseConnectionId,
388391
} = params;
389392

390393
const redirectUrlWithAuthToken = SignUp.clerk.buildUrlWithAuth(redirectUrl);
@@ -398,6 +401,7 @@ export class SignUp extends BaseResource implements SignUpResource {
398401
emailAddress,
399402
legalAccepted,
400403
oidcPrompt,
404+
enterpriseConnectionId,
401405
};
402406
return continueSignUp && this.id ? this.update(authParams) : this.create(authParams);
403407
};
@@ -551,6 +555,17 @@ export class SignUp extends BaseResource implements SignUpResource {
551555

552556
return false;
553557
}
558+
559+
__experimental_getEnterpriseConnections = (): Promise<SignUpEnterpriseConnectionResource[]> => {
560+
return BaseResource._fetch({
561+
path: `/client/sign_ups/${this.id}/enterprise_connections`,
562+
method: 'GET',
563+
}).then(res => {
564+
const enterpriseConnections = res?.response as unknown as SignUpEnterpriseConnectionJSON[];
565+
566+
return enterpriseConnections.map(enterpriseConnection => new SignUpEnterpriseConnection(enterpriseConnection));
567+
});
568+
};
554569
}
555570

556571
class SignUpFuture implements SignUpFutureResource {
@@ -889,3 +904,22 @@ class SignUpFuture implements SignUpFutureResource {
889904
});
890905
}
891906
}
907+
908+
class SignUpEnterpriseConnection extends BaseResource implements SignUpEnterpriseConnectionResource {
909+
id!: string;
910+
name!: string;
911+
912+
constructor(data: SignUpEnterpriseConnectionJSON) {
913+
super();
914+
this.fromJSON(data);
915+
}
916+
917+
protected fromJSON(data: SignUpEnterpriseConnectionJSON | null): this {
918+
if (data) {
919+
this.id = data.id;
920+
this.name = data.name;
921+
}
922+
923+
return this;
924+
}
925+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { useState } from 'react';
2+
3+
import type { LocalizationKey } from '@/ui/customizables';
4+
import { descriptors, Flex, Grid, SimpleButton, Spinner, Text } from '@/ui/customizables';
5+
import { Card } from '@/ui/elements/Card';
6+
import { useCardState } from '@/ui/elements/contexts';
7+
import { Header } from '@/ui/elements/Header';
8+
import type { InternalTheme, PropsOfComponent } from '@/ui/styledSystem';
9+
10+
type ChooseEnterpriseConnectionCardProps = {
11+
title: LocalizationKey;
12+
subtitle: LocalizationKey;
13+
onClick: (id: string) => Promise<void>;
14+
enterpriseConnections: Array<{ id: string; name: string }>;
15+
};
16+
17+
/**
18+
* @experimental
19+
*/
20+
export const ChooseEnterpriseConnectionCard = ({
21+
title,
22+
subtitle,
23+
onClick,
24+
enterpriseConnections,
25+
}: ChooseEnterpriseConnectionCardProps) => {
26+
const card = useCardState();
27+
28+
return (
29+
<Card.Root>
30+
<Card.Content>
31+
<Header.Root showLogo>
32+
<Header.Title localizationKey={title} />
33+
<Header.Subtitle localizationKey={subtitle} />
34+
</Header.Root>
35+
<Card.Alert>{card.error}</Card.Alert>
36+
37+
<Grid
38+
elementDescriptor={descriptors.enterpriseConnectionsRoot}
39+
gap={2}
40+
>
41+
{enterpriseConnections?.map(({ id, name }) => (
42+
<ChooseEnterpriseConnectionButton
43+
key={id}
44+
id={id}
45+
label={name}
46+
onClick={onClick}
47+
/>
48+
))}
49+
</Grid>
50+
</Card.Content>
51+
52+
<Card.Footer />
53+
</Card.Root>
54+
);
55+
};
56+
57+
type ChooseEnterpriseConnectionButtonProps = Omit<PropsOfComponent<typeof SimpleButton>, 'onClick'> & {
58+
id: string;
59+
label?: string;
60+
onClick: (id: string) => Promise<void>;
61+
};
62+
63+
const ChooseEnterpriseConnectionButton = (props: ChooseEnterpriseConnectionButtonProps): JSX.Element => {
64+
const { label, onClick, ...rest } = props;
65+
const [isLoading, setIsLoading] = useState(false);
66+
67+
const handleClick = () => {
68+
setIsLoading(true);
69+
void onClick(props.id).catch(() => setIsLoading(false));
70+
};
71+
72+
return (
73+
<SimpleButton
74+
elementDescriptor={descriptors.enterpriseConnectionButton}
75+
variant='outline'
76+
block
77+
isLoading={isLoading}
78+
hoverAsFocus
79+
onClick={handleClick}
80+
{...rest}
81+
sx={(theme: InternalTheme) => [
82+
{
83+
gap: theme.space.$4,
84+
position: 'relative',
85+
justifyContent: 'flex-start',
86+
},
87+
(rest as any).sx,
88+
]}
89+
>
90+
<Flex
91+
justify='center'
92+
align='center'
93+
as='span'
94+
gap={3}
95+
sx={{
96+
width: '100%',
97+
overflow: 'hidden',
98+
}}
99+
>
100+
{isLoading && (
101+
<Flex
102+
as='span'
103+
center
104+
sx={(theme: InternalTheme) => ({ flex: `0 0 ${theme.space.$4}` })}
105+
>
106+
<Spinner
107+
size='sm'
108+
elementDescriptor={descriptors.spinner}
109+
/>
110+
</Flex>
111+
)}
112+
<Text
113+
elementDescriptor={descriptors.enterpriseConnectionButtonText}
114+
as='span'
115+
truncate
116+
variant='buttonLarge'
117+
>
118+
{label}
119+
</Text>
120+
</Flex>
121+
</SimpleButton>
122+
);
123+
};

packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies';
1212
import { localizationKeys } from '../../localization';
1313
import { useRouter } from '../../router';
1414
import { AlternativeMethods } from './AlternativeMethods';
15+
import { hasMultipleEnterpriseConnections } from './shared';
1516
import { SignInFactorOneAlternativePhoneCodeCard } from './SignInFactorOneAlternativePhoneCodeCard';
1617
import { SignInFactorOneEmailCodeCard } from './SignInFactorOneEmailCodeCard';
1718
import { SignInFactorOneEmailLinkCard } from './SignInFactorOneEmailLinkCard';
19+
import { SignInFactorOneEnterpriseConnections } from './SignInFactorOneEnterpriseConnections';
1820
import { SignInFactorOneForgotPasswordCard } from './SignInFactorOneForgotPasswordCard';
1921
import { SignInFactorOnePasskey } from './SignInFactorOnePasskey';
2022
import { SignInFactorOnePasswordCard } from './SignInFactorOnePasswordCard';
@@ -122,6 +124,15 @@ function SignInFactorOneInternal(): JSX.Element {
122124
prevCurrentFactor: prev.currentFactor,
123125
}));
124126
};
127+
128+
/**
129+
* Prompt to choose between a list of enterprise connections as supported first factors
130+
* @experimental
131+
*/
132+
if (hasMultipleEnterpriseConnections(signIn.supportedFirstFactors)) {
133+
return <SignInFactorOneEnterpriseConnections />;
134+
}
135+
125136
if (showAllStrategies || showForgotPasswordStrategies) {
126137
const canGoBack = factorHasLocalStrategy(currentFactor);
127138

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { useClerk } from '@clerk/shared/react/index';
2+
import type { ComponentType } from 'react';
3+
4+
import { buildSSOCallbackURL, withRedirect } from '@/ui/common';
5+
import { ChooseEnterpriseConnectionCard } from '@/ui/common/ChooseEnterpriseConnectionCard';
6+
import { useCoreSignIn, useEnvironment, useSignInContext } from '@/ui/contexts';
7+
import { Flow, localizationKeys } from '@/ui/customizables';
8+
import { withCardStateProvider } from '@/ui/elements/contexts';
9+
import type { AvailableComponentProps } from '@/ui/types';
10+
11+
import { hasMultipleEnterpriseConnections } from './shared';
12+
13+
/**
14+
* @experimental
15+
*/
16+
const SignInFactorOneEnterpriseConnectionsInternal = () => {
17+
const ctx = useSignInContext();
18+
const { displayConfig } = useEnvironment();
19+
20+
const clerk = useClerk();
21+
const signIn = clerk.client.signIn;
22+
23+
if (!hasMultipleEnterpriseConnections(signIn.supportedFirstFactors)) {
24+
// This should not happen due to the HOC guard, but provides type safety
25+
return null;
26+
}
27+
28+
const enterpriseConnections = signIn.supportedFirstFactors.map(ff => ({
29+
id: ff.enterpriseConnectionId,
30+
name: ff.enterpriseConnectionName,
31+
}));
32+
33+
const handleEnterpriseSSO = (enterpriseConnectionId: string) => {
34+
const redirectUrl = buildSSOCallbackURL(ctx, displayConfig.signInUrl);
35+
const redirectUrlComplete = ctx.afterSignInUrl || '/';
36+
37+
return signIn.authenticateWithRedirect({
38+
strategy: 'enterprise_sso',
39+
redirectUrl,
40+
redirectUrlComplete,
41+
oidcPrompt: ctx.oidcPrompt,
42+
continueSignIn: true,
43+
enterpriseConnectionId,
44+
});
45+
};
46+
47+
return (
48+
<Flow.Part part='enterpriseConnections'>
49+
<ChooseEnterpriseConnectionCard
50+
title={localizationKeys('signIn.enterpriseConnections.title')}
51+
subtitle={localizationKeys('signIn.enterpriseConnections.subtitle')}
52+
onClick={handleEnterpriseSSO}
53+
enterpriseConnections={enterpriseConnections}
54+
/>
55+
</Flow.Part>
56+
);
57+
};
58+
59+
const withEnterpriseConnectionsGuard = <P extends AvailableComponentProps>(Component: ComponentType<P>) => {
60+
const displayName = Component.displayName || Component.name || 'Component';
61+
Component.displayName = displayName;
62+
63+
const HOC = (props: P) => {
64+
const signIn = useCoreSignIn();
65+
const signInCtx = useSignInContext();
66+
67+
return withRedirect(
68+
Component,
69+
() => !hasMultipleEnterpriseConnections(signIn.supportedFirstFactors),
70+
({ clerk }) => signInCtx.signInUrl || clerk.buildSignInUrl(),
71+
'There are no enterprise connections available to sign-in. Clerk is redirecting to the `signInUrl` instead.',
72+
)(props);
73+
};
74+
75+
HOC.displayName = `withEnterpriseConnectionsGuard(${displayName})`;
76+
77+
return HOC;
78+
};
79+
80+
export const SignInFactorOneEnterpriseConnections = withCardStateProvider(
81+
withEnterpriseConnectionsGuard(SignInFactorOneEnterpriseConnectionsInternal),
82+
);

0 commit comments

Comments
 (0)