diff --git a/.changeset/unlucky-hornets-roll.md b/.changeset/unlucky-hornets-roll.md new file mode 100644 index 00000000000..be1562f4d31 --- /dev/null +++ b/.changeset/unlucky-hornets-roll.md @@ -0,0 +1,14 @@ +--- +"@clerk/clerk-js": minor +"@clerk/types": minor +--- + +Added support for Custom OAuth providers + +- Updated strategy types to include `CustomOAuthStrategy`: + - Added the `CustomOAuthStrategy` type with the value `oauth_custom_${string}` + - Modified `OAuthStrategy` to include `CustomOAuthStrategy`: + `export type OAuthStrategy = `oauth_${OAuthProvider}` | CustomOAuthStrategy;` +- Added the `CustomOauthProvider` type with value `custom_${string}` and extended `OAuthProvider` type to include `CustomOauthProvider` +- Added support for displaying provider initials when `logo_url` is null for custom OAuth providers +- Created new `ProviderInitialIcon` internal component in order to display custom oauth provider initials if provider `logo_url` is null \ No newline at end of file diff --git a/packages/clerk-js/src/ui/common/ProviderInitialIcon.tsx b/packages/clerk-js/src/ui/common/ProviderInitialIcon.tsx new file mode 100644 index 00000000000..5976741eacb --- /dev/null +++ b/packages/clerk-js/src/ui/common/ProviderInitialIcon.tsx @@ -0,0 +1,42 @@ +import type { OAuthProvider, Web3Provider } from '@clerk/types'; + +import { Box, descriptors, Text } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; +import { common } from '../styledSystem'; + +type ProviderInitialIconProps = PropsOfComponent & { + value: string; + id: Web3Provider | OAuthProvider; +}; + +export const ProviderInitialIcon = (props: ProviderInitialIconProps) => { + const { value, id, ...rest } = props; + + return ( + ({ + ...common.centeredFlex('inline-flex'), + width: t.space.$4, + height: t.space.$4, + borderRadius: t.radii.$sm, + color: t.colors.$colorTextOnPrimaryBackground, + backgroundColor: t.colors.$primary500, + })} + {...rest} + > + + {value[0]} + + + ); +}; diff --git a/packages/clerk-js/src/ui/common/index.ts b/packages/clerk-js/src/ui/common/index.ts index 95597333ac5..13a2268f6b2 100644 --- a/packages/clerk-js/src/ui/common/index.ts +++ b/packages/clerk-js/src/ui/common/index.ts @@ -4,6 +4,7 @@ export * from './CalloutWithAction'; export * from './forms'; export * from './Gate'; export * from './InfiniteListSpinner'; +export * from './ProviderInitialIcon'; export * from './redirects'; export * from './verification'; export * from './withRedirect'; diff --git a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx index 973efa5bc41..f49eab628b0 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsMenu.tsx @@ -2,6 +2,7 @@ import { useUser } from '@clerk/shared/react'; import type { OAuthProvider, OAuthStrategy } from '@clerk/types'; import { appendModalState } from '../../../utils'; +import { ProviderInitialIcon } from '../../common'; import { useUserProfileContext } from '../../contexts'; import { descriptors, Image, localizationKeys } from '../../customizables'; import { ProfileSection, useCardState } from '../../elements'; @@ -61,36 +62,47 @@ export const AddConnectedAccount = () => { triggerLocalizationKey={localizationKeys('userProfile.start.connectedAccountsSection.primaryButton')} id='connectedAccounts' > - {unconnectedStrategies.map(strategy => ( - connect(strategy)} - isDisabled={card.isLoading} - variant='ghost' - isLoading={card.loadingMetadata === strategy} - focusRing={false} - closeAfterClick={false} - localizationKey={localizationKeys('userProfile.connectedAccountPage.socialButtonsBlockButton', { - provider: strategyToDisplayData[strategy].name, - })} - sx={t => ({ - justifyContent: 'start', - gap: t.space.$2, - })} - leftIcon={ - {`Connect ({ width: theme.sizes.$4 })} - /> - } - /> - ))} + {unconnectedStrategies.map(strategy => { + const imageOrInitial = strategyToDisplayData[strategy].iconUrl ? ( + {`Connect ({ width: theme.sizes.$4 })} + /> + ) : ( + + ); + + return ( + connect(strategy)} + isDisabled={card.isLoading} + variant='ghost' + isLoading={card.loadingMetadata === strategy} + focusRing={false} + closeAfterClick={false} + localizationKey={localizationKeys('userProfile.connectedAccountPage.socialButtonsBlockButton', { + provider: strategyToDisplayData[strategy].name, + })} + sx={t => ({ + justifyContent: 'start', + gap: t.space.$2, + })} + leftIcon={imageOrInitial} + /> + ); + })} ); }; diff --git a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx index d29508e7235..0e10ec4b36e 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx @@ -2,6 +2,7 @@ import { useUser } from '@clerk/shared/react'; import type { ExternalAccountResource, OAuthProvider, OAuthScope, OAuthStrategy } from '@clerk/types'; import { appendModalState } from '../../../utils'; +import { ProviderInitialIcon } from '../../common'; import { useUserProfileContext } from '../../contexts'; import { Badge, Box, descriptors, Flex, Image, localizationKeys, Text } from '../../customizables'; import { Card, ProfileSection, ThreeDotsMenu, useCardState, withCardStateProvider } from '../../elements'; @@ -9,7 +10,7 @@ import { Action } from '../../elements/Action'; import { useActionContext } from '../../elements/Action/ActionRoot'; import { useEnabledThirdPartyProviders } from '../../hooks'; import { useRouter } from '../../router'; -import type { PropsOfComponent } from '../../styledSystem'; +import { type PropsOfComponent } from '../../styledSystem'; import { handleError } from '../../utils'; import { AddConnectedAccount } from './ConnectedAccountsMenu'; import { RemoveConnectedAccountForm } from './RemoveResourceForm'; @@ -59,17 +60,27 @@ export const ConnectedAccountsSection = withCardStateProvider(() => { ? error : localizationKeys('userProfile.start.connectedAccountsSection.subtitle__reauthorize'); + const ImageOrInitial = () => + providerToDisplayData[account.provider].iconUrl ? ( + {providerToDisplayData[account.provider].name} ({ width: theme.sizes.$4, flexShrink: 0 })} + /> + ) : ( + + ); + return ( ({ overflow: 'hidden', gap: t.space.$2 })}> - {providerToDisplayData[account.provider].name} ({ width: theme.sizes.$4, flexShrink: 0 })} - /> + { ? firstStrategyRef : null; + const imageOrInitial = strategyToDisplayData[strategy].iconUrl ? ( + {`Sign ({ width: theme.sizes.$4, height: 'auto', maxWidth: '100%' })} + /> + ) : ( + + ); + return ( { isDisabled={card.isLoading} label={label} textLocalizationKey={localizedText} - icon={ - {`Sign ({ width: theme.sizes.$4, height: 'auto', maxWidth: '100%' })} - /> - } + icon={imageOrInitial} /> ); })} diff --git a/packages/clerk-js/src/ui/hooks/__tests__/useEnabledThirdPartyProviders.test.tsx b/packages/clerk-js/src/ui/hooks/__tests__/useEnabledThirdPartyProviders.test.tsx new file mode 100644 index 00000000000..6675ba6ff81 --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/__tests__/useEnabledThirdPartyProviders.test.tsx @@ -0,0 +1,121 @@ +import { OAUTH_PROVIDERS } from '@clerk/types'; +import { renderHook } from '@testing-library/react'; + +import { bindCreateFixtures } from '../../../testUtils'; +import { useEnabledThirdPartyProviders } from '../useEnabledThirdPartyProviders'; + +const { createFixtures } = bindCreateFixtures('SignUp'); + +describe('useEnabledThirdPartyProviders', () => { + it('should returns correct strategies', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'apple' }); + f.withSocialProvider({ provider: 'facebook' }); + }); + const { result } = renderHook(() => useEnabledThirdPartyProviders(), { wrapper }); + + const { authenticatableOauthStrategies } = result.current; + + expect(authenticatableOauthStrategies).toStrictEqual(['oauth_apple', 'oauth_facebook']); + }); + + it('should returns correct sorting for all default strategies', async () => { + const { wrapper } = await createFixtures(f => { + OAUTH_PROVIDERS.map(p => f.withSocialProvider({ provider: p.provider })); + }); + const { result } = renderHook(() => useEnabledThirdPartyProviders(), { wrapper }); + + const { authenticatableOauthStrategies } = result.current; + + expect(authenticatableOauthStrategies).toStrictEqual([ + 'oauth_apple', + 'oauth_atlassian', + 'oauth_bitbucket', + 'oauth_box', + 'oauth_coinbase', + 'oauth_discord', + 'oauth_dropbox', + 'oauth_enstall', + 'oauth_facebook', + 'oauth_github', + 'oauth_gitlab', + 'oauth_google', + 'oauth_hubspot', + 'oauth_instagram', + 'oauth_line', + 'oauth_linear', + 'oauth_linkedin', + 'oauth_linkedin_oidc', + 'oauth_microsoft', + 'oauth_notion', + 'oauth_slack', + 'oauth_spotify', + 'oauth_tiktok', + 'oauth_twitch', + 'oauth_twitter', + 'oauth_x', + 'oauth_xero', + ]); + }); + + it('should returns correct custom strategies', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'custom_google' }); + f.withSocialProvider({ provider: 'custom_acme' }); + }); + const { result } = renderHook(() => useEnabledThirdPartyProviders(), { wrapper }); + + const { authenticatableOauthStrategies } = result.current; + + expect(authenticatableOauthStrategies).toStrictEqual(['oauth_custom_acme', 'oauth_custom_google']); + }); + + it('should returns correct default and custom strategies', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'apple' }); + f.withSocialProvider({ provider: 'facebook' }); + f.withSocialProvider({ provider: 'custom_google' }); + }); + const { result } = renderHook(() => useEnabledThirdPartyProviders(), { wrapper }); + + const { authenticatableOauthStrategies } = result.current; + + expect(authenticatableOauthStrategies).toStrictEqual(['oauth_apple', 'oauth_facebook', 'oauth_custom_google']); + }); + + it('should returns sorted default and custom strategies correctly', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'custom_google' }); + f.withSocialProvider({ provider: 'apple' }); + f.withSocialProvider({ provider: 'facebook' }); + }); + const { result } = renderHook(() => useEnabledThirdPartyProviders(), { wrapper }); + + const { authenticatableOauthStrategies } = result.current; + + expect(authenticatableOauthStrategies).toStrictEqual(['oauth_apple', 'oauth_facebook', 'oauth_custom_google']); + }); + + it('should returns sorted default and custom strategies correctly', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'custom_google' }); + f.withSocialProvider({ provider: 'custom_patreon' }); + f.withSocialProvider({ provider: 'apple' }); + f.withSocialProvider({ provider: 'facebook' }); + f.withSocialProvider({ provider: 'microsoft' }); + f.withSocialProvider({ provider: 'slack' }); + }); + const { result } = renderHook(() => useEnabledThirdPartyProviders(), { wrapper }); + + const { authenticatableOauthStrategies } = result.current; + + expect(authenticatableOauthStrategies).toStrictEqual([ + 'oauth_apple', + 'oauth_facebook', + 'oauth_custom_google', + 'oauth_microsoft', + 'oauth_custom_patreon', + 'oauth_slack', + ]); + }); +}); diff --git a/packages/clerk-js/src/ui/hooks/useEnabledThirdPartyProviders.tsx b/packages/clerk-js/src/ui/hooks/useEnabledThirdPartyProviders.tsx index 1063ba7a23e..c07c2674e8c 100644 --- a/packages/clerk-js/src/ui/hooks/useEnabledThirdPartyProviders.tsx +++ b/packages/clerk-js/src/ui/hooks/useEnabledThirdPartyProviders.tsx @@ -37,16 +37,54 @@ const strategyToDisplayData: ThirdPartyStrategyToDataMap = fromEntries( ) as ThirdPartyStrategyToDataMap; export const useEnabledThirdPartyProviders = () => { - const { socialProviderStrategies, web3FirstFactors, authenticatableSocialStrategies } = useEnvironment().userSettings; + const { socialProviderStrategies, web3FirstFactors, authenticatableSocialStrategies, social } = + useEnvironment().userSettings; // Filter out any OAuth strategies that are not yet known, they are not included in our types. const knownSocialProviderStrategies = socialProviderStrategies.filter(s => oauthStrategies.includes(s)); + const customSocialProviderStrategies = socialProviderStrategies.filter( + s => !oauthStrategies.includes(s) && s.startsWith('oauth_custom_'), + ); + const knownAuthenticatableSocialStrategies = authenticatableSocialStrategies.filter(s => oauthStrategies.includes(s)); + const customAuthenticatableSocialStrategies = authenticatableSocialStrategies.filter( + s => !oauthStrategies.includes(s) && s.startsWith('oauth_custom_'), + ); + + customSocialProviderStrategies.forEach(s => { + const providerName = s.replace('oauth_', '') as OAuthProvider; + providerToDisplayData[providerName] = { + strategy: s, + name: social[s].name, + iconUrl: social[s].logo_url || '', + }; + }); + + customAuthenticatableSocialStrategies.forEach(s => { + const providerId = s.replace('oauth_', '') as OAuthProvider; + strategyToDisplayData[s] = { + id: providerId, + iconUrl: social[s].logo_url || '', + name: social[s].name, + }; + }); + + const authenticatableOauthStrategies = [ + ...knownAuthenticatableSocialStrategies, + ...customAuthenticatableSocialStrategies, + ]; + + // Sort the authenticatableOauthStrategies by name + authenticatableOauthStrategies.sort((a, b) => { + const aName = a.replace(/^oauth_custom_|^oauth_/, ''); + const bName = b.replace(/^oauth_custom_|^oauth_/, ''); + return aName.localeCompare(bName); + }); return { - strategies: [...knownSocialProviderStrategies, ...web3FirstFactors], + strategies: [...knownSocialProviderStrategies, ...web3FirstFactors, ...customSocialProviderStrategies], web3Strategies: [...web3FirstFactors], - authenticatableOauthStrategies: [...knownAuthenticatableSocialStrategies], + authenticatableOauthStrategies, strategyToDisplayData: strategyToDisplayData, providerToDisplayData: providerToDisplayData, }; diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index c479de540ef..2276dea23b6 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -169,6 +169,7 @@ export type ElementsConfig = { socialButtonsBlockButton: WithOptions; socialButtonsBlockButtonText: WithOptions; socialButtonsProviderIcon: WithOptions; + socialButtonsProviderInitialIcon: WithOptions; enterpriseButtonsProviderIcon: WithOptions; diff --git a/packages/types/src/oauth.ts b/packages/types/src/oauth.ts index 9cc3728b603..397ebdcc9fc 100644 --- a/packages/types/src/oauth.ts +++ b/packages/types/src/oauth.ts @@ -36,6 +36,7 @@ export type SlackOauthProvider = 'slack'; export type LinearOauthProvider = 'linear'; export type XOauthProvider = 'x'; export type EnstallOauthProvider = 'enstall'; +export type CustomOauthProvider = `custom_${string}`; export type OAuthProvider = | FacebookOauthProvider @@ -64,7 +65,8 @@ export type OAuthProvider = | SlackOauthProvider | LinearOauthProvider | XOauthProvider - | EnstallOauthProvider; + | EnstallOauthProvider + | CustomOauthProvider; export const OAUTH_PROVIDERS: OAuthProviderData[] = [ { diff --git a/packages/types/src/strategies.ts b/packages/types/src/strategies.ts index ce2457dc61a..c46801542da 100644 --- a/packages/types/src/strategies.ts +++ b/packages/types/src/strategies.ts @@ -12,8 +12,9 @@ export type TOTPStrategy = 'totp'; export type BackupCodeStrategy = 'backup_code'; export type ResetPasswordPhoneCodeStrategy = 'reset_password_phone_code'; export type ResetPasswordEmailCodeStrategy = 'reset_password_email_code'; +export type CustomOAuthStrategy = `oauth_custom_${string}`; -export type OAuthStrategy = `oauth_${OAuthProvider}`; +export type OAuthStrategy = `oauth_${OAuthProvider}` | CustomOAuthStrategy; export type Web3Strategy = `web3_${Web3Provider}_signature`; export type SamlStrategy = 'saml'; diff --git a/packages/types/src/userSettings.ts b/packages/types/src/userSettings.ts index 948a38d9880..ca61c419537 100644 --- a/packages/types/src/userSettings.ts +++ b/packages/types/src/userSettings.ts @@ -21,6 +21,8 @@ export type OAuthProviderSettings = { required: boolean; authenticatable: boolean; strategy: OAuthStrategy; + name: string; + logo_url: string | null; }; export type AttributeDataJSON = {