Skip to content

Commit

Permalink
fix(clerk-js): Improve UX for recoverable actions in ConnectedAccounts (
Browse files Browse the repository at this point in the history
  • Loading branch information
panteliselef authored Jul 19, 2024
1 parent 6374a88 commit b2788f6
Show file tree
Hide file tree
Showing 38 changed files with 628 additions and 130 deletions.
6 changes: 6 additions & 0 deletions .changeset/chilly-pens-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clerk/localizations": minor
---

- Introduced `subtitle__disconnected` under `userProfile.start.connectedAccountsSection`
- Aligned `signUp.start.clientMismatch` and `signIn.start.clientMismatch` to all languages.
5 changes: 5 additions & 0 deletions .changeset/fair-crabs-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/clerk-js": minor
---

Improve UX in ConnectedAccounts by converting the error into a useful, user-friendly message with a visible way to take action.
6 changes: 6 additions & 0 deletions .changeset/polite-cheetahs-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clerk/types": minor
---

- Introduced `subtitle__disconnected` under `userProfile.start.connectedAccountsSection`
- Deprecated `userProfile.start.connectedAccountsSection.actionLabel__reauthorize` and `userProfile.start.connectedAccountsSection.subtitle__reauthorize`
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import type { ExternalAccountResource, OAuthProvider, OAuthScope, OAuthStrategy
import { appendModalState } from '../../../utils';
import { ProviderInitialIcon } from '../../common';
import { useUserProfileContext } from '../../contexts';
import { Badge, Box, descriptors, Flex, Image, localizationKeys, Text } from '../../customizables';
import { Box, Button, 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 All @@ -27,11 +27,28 @@ const RemoveConnectedAccountScreen = (props: RemoveConnectedAccountScreenProps)
);
};

const errorCodesForReconnect = [
/**
* Some Oauth providers will generate a refresh token only the first time the user gives consent to the app.
*/
'external_account_missing_refresh_token',
/**
* Provider is experiencing an issue currently.
*/
'oauth_fetch_user_error',
/**
* Provider is experiencing an issue currently (same as above).
*/
'oauth_token_exchange_error',
/**
* User's associated email address is required to be verified, because it was initially created as unverified.
*/
'external_account_email_address_verification_required',
];

export const ConnectedAccountsSection = withCardStateProvider(() => {
const { user } = useUser();
const card = useCardState();
const { providerToDisplayData } = useEnabledThirdPartyProviders();
const { additionalOAuthScopes } = useUserProfileContext();

if (!user) {
return null;
Expand All @@ -51,79 +68,12 @@ export const ConnectedAccountsSection = withCardStateProvider(() => {
<Card.Alert>{card.error}</Card.Alert>
<Action.Root>
<ProfileSection.ItemList id='connectedAccounts'>
{accounts.map(account => {
const label = account.username || account.emailAddress;
const error = account.verification?.error?.longMessage;
const additionalScopes = findAdditionalScopes(account, additionalOAuthScopes);
const reauthorizationRequired = additionalScopes.length > 0 && account.approvedScopes != '';
const errorMessage = !reauthorizationRequired
? 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 })}>
<ImageOrInitial />
<Box sx={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<Flex
gap={2}
center
>
<Text sx={t => ({ color: t.colors.$colorText })}>{`${
providerToDisplayData[account.provider].name
}`}</Text>
<Text
truncate
as='span'
colorScheme='secondary'
>
{label ? `• ${label}` : ''}
</Text>
{(error || reauthorizationRequired) && (
<Badge
colorScheme='danger'
localizationKey={localizationKeys('badge__requiresAction')}
/>
)}
</Flex>
</Box>
</Flex>

<ConnectedAccountMenu account={account} />
</ProfileSection.Item>
{(error || reauthorizationRequired) && (
<Text
colorScheme='danger'
sx={t => ({ padding: `${t.sizes.$none} ${t.sizes.$4} ${t.sizes.$1x5} ${t.sizes.$8x5}` })}
localizationKey={errorMessage}
/>
)}

<Action.Open value='remove'>
<Action.Card variant='destructive'>
<RemoveConnectedAccountScreen accountId={account.id} />
</Action.Card>
</Action.Open>
</Action.Root>
);
})}
{accounts.map(account => (
<ConnectedAccount
key={account.id}
account={account}
/>
))}
</ProfileSection.ItemList>

<AddConnectedAccount />
Expand All @@ -132,32 +82,37 @@ export const ConnectedAccountsSection = withCardStateProvider(() => {
);
});

const ConnectedAccountMenu = ({ account }: { account: ExternalAccountResource }) => {
const card = useCardState();
const { user } = useUser();
const { navigate } = useRouter();
const { open } = useActionContext();
const error = account.verification?.error?.longMessage;
const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => {
const { additionalOAuthScopes, componentName, mode } = useUserProfileContext();
const { navigate } = useRouter();
const { user } = useUser();
const card = useCardState();

if (!user) {
return null;
}

const isModal = mode === 'modal';
const { providerToDisplayData } = useEnabledThirdPartyProviders();
const label = account.username || account.emailAddress;
const fallbackErrorMessage = account.verification?.error?.longMessage;
const additionalScopes = findAdditionalScopes(account, additionalOAuthScopes);
const reauthorizationRequired = additionalScopes.length > 0 && account.approvedScopes != '';
const actionLabel = !reauthorizationRequired
? localizationKeys('userProfile.start.connectedAccountsSection.actionLabel__connectionFailed')
: localizationKeys('userProfile.start.connectedAccountsSection.actionLabel__reauthorize');
const shouldDisplayReconnect =
errorCodesForReconnect.includes(account.verification?.error?.code || '') || reauthorizationRequired;

const handleOnClick = async () => {
const connectedAccountErrorMessage = shouldDisplayReconnect
? localizationKeys(`userProfile.start.connectedAccountsSection.subtitle__disconnected`)
: fallbackErrorMessage;

const reconnect = async () => {
const redirectUrl = isModal ? appendModalState({ url: window.location.href, componentName }) : window.location.href;

try {
let response: ExternalAccountResource;
if (reauthorizationRequired) {
response = await account.reauthorize({ additionalScopes, redirectUrl });
} else {
if (!user) {
throw Error('user is not defined');
}

response = await user.createExternalAccount({
strategy: account.verification!.strategy as OAuthStrategy,
redirectUrl,
Expand All @@ -171,14 +126,101 @@ const ConnectedAccountMenu = ({ account }: { account: ExternalAccountResource })
}
};

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 })}>
<ImageOrInitial />
<Box sx={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<Flex
gap={2}
center
>
<Text sx={t => ({ color: t.colors.$colorText })}>{`${
providerToDisplayData[account.provider].name
}`}</Text>
<Text
truncate
as='span'
colorScheme='secondary'
>
{label ? `• ${label}` : ''}
</Text>
</Flex>
</Box>
</Flex>

<ConnectedAccountMenu />
</ProfileSection.Item>
{shouldDisplayReconnect && (
<Box
sx={t => ({
padding: `${t.sizes.$none} ${t.sizes.$none} ${t.sizes.$1x5} ${t.sizes.$8x5}`,
})}
>
<Text
colorScheme='secondary'
sx={t => ({
paddingRight: t.sizes.$1x5,
display: 'inline-block',
})}
localizationKey={connectedAccountErrorMessage}
/>

<Button
sx={{
display: 'inline-block',
}}
onClick={reconnect}
variant='link'
localizationKey={localizationKeys(
'userProfile.start.connectedAccountsSection.actionLabel__connectionFailed',
)}
/>
</Box>
)}

{account.verification?.error?.code && !shouldDisplayReconnect && (
<Text
colorScheme='danger'
sx={t => ({
padding: `${t.sizes.$none} ${t.sizes.$1x5} ${t.sizes.$1x5} ${t.sizes.$8x5}`,
})}
>
{fallbackErrorMessage}
</Text>
)}

<Action.Open value='remove'>
<Action.Card variant='destructive'>
<RemoveConnectedAccountScreen accountId={account.id} />
</Action.Card>
</Action.Open>
</Action.Root>
);
};

const ConnectedAccountMenu = () => {
const { open } = useActionContext();

const actions = (
[
error || reauthorizationRequired
? {
label: actionLabel,
onClick: handleOnClick,
}
: null,
{
label: localizationKeys('userProfile.start.connectedAccountsSection.destructiveActionTitle'),
isDestructive: true,
Expand Down
Loading

0 comments on commit b2788f6

Please sign in to comment.