Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/unlucky-hornets-roll.md
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions packages/clerk-js/src/ui/common/ProviderInitialIcon.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Box> & {
value: string;
id: Web3Provider | OAuthProvider;
};

export const ProviderInitialIcon = (props: ProviderInitialIconProps) => {
const { value, id, ...rest } = props;

return (
<Box
as='span'
elementDescriptor={[descriptors.providerIcon, descriptors.socialButtonsProviderInitialIcon]}
elementId={descriptors.socialButtonsProviderInitialIcon.setId(id)}
sx={t => ({
...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}
>
<Text
as='span'
variant='buttonSmall'
sx={{
...common.centeredFlex('inline-flex'),
width: '100%',
}}
>
{value[0]}
</Text>
</Box>
);
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,36 +62,47 @@ export const AddConnectedAccount = () => {
triggerLocalizationKey={localizationKeys('userProfile.start.connectedAccountsSection.primaryButton')}
id='connectedAccounts'
>
{unconnectedStrategies.map(strategy => (
<ProfileSection.ActionMenuItem
key={strategy}
id={strategyToDisplayData[strategy].id}
onClick={() => 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={
<Image
isLoading={card.loadingMetadata === strategy}
isDisabled={card.isLoading}
elementDescriptor={descriptors.providerIcon}
elementId={descriptors.providerIcon.setId(strategyToDisplayData[strategy].id)}
src={strategyToDisplayData[strategy].iconUrl}
alt={`Connect ${strategyToDisplayData[strategy].name} account`}
sx={theme => ({ width: theme.sizes.$4 })}
/>
}
/>
))}
{unconnectedStrategies.map(strategy => {
const imageOrInitial = strategyToDisplayData[strategy].iconUrl ? (
<Image
isLoading={card.loadingMetadata === strategy}
isDisabled={card.isLoading}
elementDescriptor={descriptors.providerIcon}
elementId={descriptors.providerIcon.setId(strategyToDisplayData[strategy].id)}
src={strategyToDisplayData[strategy].iconUrl}
alt={`Connect ${strategyToDisplayData[strategy].name} account`}
sx={theme => ({ width: theme.sizes.$4 })}
/>
) : (
<ProviderInitialIcon
id={strategyToDisplayData[strategy].id}
value={strategyToDisplayData[strategy].name}
isLoading={card.loadingMetadata === strategy}
isDisabled={card.isLoading}
/>
);

return (
<ProfileSection.ActionMenuItem
key={strategy}
id={strategyToDisplayData[strategy].id}
onClick={() => 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}
/>
);
})}
</ProfileSection.ActionMenu>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ 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';
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';
Expand Down Expand Up @@ -59,17 +60,27 @@ export const ConnectedAccountsSection = withCardStateProvider(() => {
? error
: localizationKeys('userProfile.start.connectedAccountsSection.subtitle__reauthorize');

const ImageOrInitial = () =>
providerToDisplayData[account.provider].iconUrl ? (
<Image
elementDescriptor={[descriptors.providerIcon]}
elementId={descriptors.socialButtonsProviderIcon.setId(account.provider)}
alt={providerToDisplayData[account.provider].name}
src={providerToDisplayData[account.provider].iconUrl}
sx={theme => ({ width: theme.sizes.$4, flexShrink: 0 })}
/>
) : (
<ProviderInitialIcon
id={account.provider}
value={providerToDisplayData[account.provider].name}
/>
);

return (
<Action.Root key={account.id}>
<ProfileSection.Item id='connectedAccounts'>
<Flex sx={t => ({ overflow: 'hidden', gap: t.space.$2 })}>
<Image
elementDescriptor={[descriptors.providerIcon]}
elementId={descriptors.socialButtonsProviderIcon.setId(account.provider)}
alt={providerToDisplayData[account.provider].name}
src={providerToDisplayData[account.provider].iconUrl}
sx={theme => ({ width: theme.sizes.$4, flexShrink: 0 })}
/>
<ImageOrInitial />
<Box sx={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<Flex
gap={2}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'socialButtonsBlockButton',
'socialButtonsBlockButtonText',
'socialButtonsProviderIcon',
'socialButtonsProviderInitialIcon',

'enterpriseButtonsProviderIcon',
'alternativeMethods',
Expand Down
32 changes: 21 additions & 11 deletions packages/clerk-js/src/ui/elements/SocialButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { OAuthProvider, OAuthStrategy, Web3Provider, Web3Strategy } from '@
import type { Ref } from 'react';
import React, { forwardRef, isValidElement } from 'react';

import { ProviderInitialIcon } from '../common';
import type { LocalizationKey } from '../customizables';
import {
Button,
Expand Down Expand Up @@ -126,6 +127,25 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => {
? firstStrategyRef
: null;

const imageOrInitial = strategyToDisplayData[strategy].iconUrl ? (
<Image
elementDescriptor={[descriptors.providerIcon, descriptors.socialButtonsProviderIcon]}
elementId={descriptors.socialButtonsProviderIcon.setId(strategyToDisplayData[strategy].id)}
isLoading={card.loadingMetadata === strategy}
isDisabled={card.isLoading}
src={strategyToDisplayData[strategy].iconUrl}
alt={`Sign in with ${strategyToDisplayData[strategy].name}`}
sx={theme => ({ width: theme.sizes.$4, height: 'auto', maxWidth: '100%' })}
/>
) : (
<ProviderInitialIcon
id={strategyToDisplayData[strategy].id}
value={strategyToDisplayData[strategy].name}
isLoading={card.loadingMetadata === strategy}
isDisabled={card.isLoading}
/>
);

return (
<ButtonElement
key={strategy}
Expand All @@ -136,17 +156,7 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => {
isDisabled={card.isLoading}
label={label}
textLocalizationKey={localizedText}
icon={
<Image
elementDescriptor={[descriptors.providerIcon, descriptors.socialButtonsProviderIcon]}
elementId={descriptors.socialButtonsProviderIcon.setId(strategyToDisplayData[strategy].id)}
isLoading={card.loadingMetadata === strategy}
isDisabled={card.isLoading}
src={strategyToDisplayData[strategy].iconUrl}
alt={`Sign in with ${strategyToDisplayData[strategy].name}`}
sx={theme => ({ width: theme.sizes.$4, height: 'auto', maxWidth: '100%' })}
/>
}
icon={imageOrInitial}
/>
);
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
Loading