Skip to content

Commit b2788f6

Browse files
authored
fix(clerk-js): Improve UX for recoverable actions in ConnectedAccounts (#3723)
1 parent 6374a88 commit b2788f6

38 files changed

+628
-130
lines changed

.changeset/chilly-pens-grab.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clerk/localizations": minor
3+
---
4+
5+
- Introduced `subtitle__disconnected` under `userProfile.start.connectedAccountsSection`
6+
- Aligned `signUp.start.clientMismatch` and `signIn.start.clientMismatch` to all languages.

.changeset/fair-crabs-remember.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/clerk-js": minor
3+
---
4+
5+
Improve UX in ConnectedAccounts by converting the error into a useful, user-friendly message with a visible way to take action.

.changeset/polite-cheetahs-wait.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clerk/types": minor
3+
---
4+
5+
- Introduced `subtitle__disconnected` under `userProfile.start.connectedAccountsSection`
6+
- Deprecated `userProfile.start.connectedAccountsSection.actionLabel__reauthorize` and `userProfile.start.connectedAccountsSection.subtitle__reauthorize`

packages/clerk-js/src/ui/components/UserProfile/ConnectedAccountsSection.tsx

Lines changed: 139 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import type { ExternalAccountResource, OAuthProvider, OAuthScope, OAuthStrategy
44
import { appendModalState } from '../../../utils';
55
import { ProviderInitialIcon } from '../../common';
66
import { useUserProfileContext } from '../../contexts';
7-
import { Badge, Box, descriptors, Flex, Image, localizationKeys, Text } from '../../customizables';
7+
import { Box, Button, descriptors, Flex, Image, localizationKeys, Text } from '../../customizables';
88
import { Card, ProfileSection, ThreeDotsMenu, useCardState, withCardStateProvider } from '../../elements';
99
import { Action } from '../../elements/Action';
1010
import { useActionContext } from '../../elements/Action/ActionRoot';
1111
import { useEnabledThirdPartyProviders } from '../../hooks';
1212
import { useRouter } from '../../router';
13-
import { type PropsOfComponent } from '../../styledSystem';
13+
import type { PropsOfComponent } from '../../styledSystem';
1414
import { handleError } from '../../utils';
1515
import { AddConnectedAccount } from './ConnectedAccountsMenu';
1616
import { RemoveConnectedAccountForm } from './RemoveResourceForm';
@@ -27,11 +27,28 @@ const RemoveConnectedAccountScreen = (props: RemoveConnectedAccountScreenProps)
2727
);
2828
};
2929

30+
const errorCodesForReconnect = [
31+
/**
32+
* Some Oauth providers will generate a refresh token only the first time the user gives consent to the app.
33+
*/
34+
'external_account_missing_refresh_token',
35+
/**
36+
* Provider is experiencing an issue currently.
37+
*/
38+
'oauth_fetch_user_error',
39+
/**
40+
* Provider is experiencing an issue currently (same as above).
41+
*/
42+
'oauth_token_exchange_error',
43+
/**
44+
* User's associated email address is required to be verified, because it was initially created as unverified.
45+
*/
46+
'external_account_email_address_verification_required',
47+
];
48+
3049
export const ConnectedAccountsSection = withCardStateProvider(() => {
3150
const { user } = useUser();
3251
const card = useCardState();
33-
const { providerToDisplayData } = useEnabledThirdPartyProviders();
34-
const { additionalOAuthScopes } = useUserProfileContext();
3552

3653
if (!user) {
3754
return null;
@@ -51,79 +68,12 @@ export const ConnectedAccountsSection = withCardStateProvider(() => {
5168
<Card.Alert>{card.error}</Card.Alert>
5269
<Action.Root>
5370
<ProfileSection.ItemList id='connectedAccounts'>
54-
{accounts.map(account => {
55-
const label = account.username || account.emailAddress;
56-
const error = account.verification?.error?.longMessage;
57-
const additionalScopes = findAdditionalScopes(account, additionalOAuthScopes);
58-
const reauthorizationRequired = additionalScopes.length > 0 && account.approvedScopes != '';
59-
const errorMessage = !reauthorizationRequired
60-
? error
61-
: localizationKeys('userProfile.start.connectedAccountsSection.subtitle__reauthorize');
62-
63-
const ImageOrInitial = () =>
64-
providerToDisplayData[account.provider].iconUrl ? (
65-
<Image
66-
elementDescriptor={[descriptors.providerIcon]}
67-
elementId={descriptors.socialButtonsProviderIcon.setId(account.provider)}
68-
alt={providerToDisplayData[account.provider].name}
69-
src={providerToDisplayData[account.provider].iconUrl}
70-
sx={theme => ({ width: theme.sizes.$4, flexShrink: 0 })}
71-
/>
72-
) : (
73-
<ProviderInitialIcon
74-
id={account.provider}
75-
value={providerToDisplayData[account.provider].name}
76-
/>
77-
);
78-
79-
return (
80-
<Action.Root key={account.id}>
81-
<ProfileSection.Item id='connectedAccounts'>
82-
<Flex sx={t => ({ overflow: 'hidden', gap: t.space.$2 })}>
83-
<ImageOrInitial />
84-
<Box sx={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
85-
<Flex
86-
gap={2}
87-
center
88-
>
89-
<Text sx={t => ({ color: t.colors.$colorText })}>{`${
90-
providerToDisplayData[account.provider].name
91-
}`}</Text>
92-
<Text
93-
truncate
94-
as='span'
95-
colorScheme='secondary'
96-
>
97-
{label ? `• ${label}` : ''}
98-
</Text>
99-
{(error || reauthorizationRequired) && (
100-
<Badge
101-
colorScheme='danger'
102-
localizationKey={localizationKeys('badge__requiresAction')}
103-
/>
104-
)}
105-
</Flex>
106-
</Box>
107-
</Flex>
108-
109-
<ConnectedAccountMenu account={account} />
110-
</ProfileSection.Item>
111-
{(error || reauthorizationRequired) && (
112-
<Text
113-
colorScheme='danger'
114-
sx={t => ({ padding: `${t.sizes.$none} ${t.sizes.$4} ${t.sizes.$1x5} ${t.sizes.$8x5}` })}
115-
localizationKey={errorMessage}
116-
/>
117-
)}
118-
119-
<Action.Open value='remove'>
120-
<Action.Card variant='destructive'>
121-
<RemoveConnectedAccountScreen accountId={account.id} />
122-
</Action.Card>
123-
</Action.Open>
124-
</Action.Root>
125-
);
126-
})}
71+
{accounts.map(account => (
72+
<ConnectedAccount
73+
key={account.id}
74+
account={account}
75+
/>
76+
))}
12777
</ProfileSection.ItemList>
12878

12979
<AddConnectedAccount />
@@ -132,32 +82,37 @@ export const ConnectedAccountsSection = withCardStateProvider(() => {
13282
);
13383
});
13484

135-
const ConnectedAccountMenu = ({ account }: { account: ExternalAccountResource }) => {
136-
const card = useCardState();
137-
const { user } = useUser();
138-
const { navigate } = useRouter();
139-
const { open } = useActionContext();
140-
const error = account.verification?.error?.longMessage;
85+
const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => {
14186
const { additionalOAuthScopes, componentName, mode } = useUserProfileContext();
87+
const { navigate } = useRouter();
88+
const { user } = useUser();
89+
const card = useCardState();
90+
91+
if (!user) {
92+
return null;
93+
}
94+
14295
const isModal = mode === 'modal';
96+
const { providerToDisplayData } = useEnabledThirdPartyProviders();
97+
const label = account.username || account.emailAddress;
98+
const fallbackErrorMessage = account.verification?.error?.longMessage;
14399
const additionalScopes = findAdditionalScopes(account, additionalOAuthScopes);
144100
const reauthorizationRequired = additionalScopes.length > 0 && account.approvedScopes != '';
145-
const actionLabel = !reauthorizationRequired
146-
? localizationKeys('userProfile.start.connectedAccountsSection.actionLabel__connectionFailed')
147-
: localizationKeys('userProfile.start.connectedAccountsSection.actionLabel__reauthorize');
101+
const shouldDisplayReconnect =
102+
errorCodesForReconnect.includes(account.verification?.error?.code || '') || reauthorizationRequired;
148103

149-
const handleOnClick = async () => {
104+
const connectedAccountErrorMessage = shouldDisplayReconnect
105+
? localizationKeys(`userProfile.start.connectedAccountsSection.subtitle__disconnected`)
106+
: fallbackErrorMessage;
107+
108+
const reconnect = async () => {
150109
const redirectUrl = isModal ? appendModalState({ url: window.location.href, componentName }) : window.location.href;
151110

152111
try {
153112
let response: ExternalAccountResource;
154113
if (reauthorizationRequired) {
155114
response = await account.reauthorize({ additionalScopes, redirectUrl });
156115
} else {
157-
if (!user) {
158-
throw Error('user is not defined');
159-
}
160-
161116
response = await user.createExternalAccount({
162117
strategy: account.verification!.strategy as OAuthStrategy,
163118
redirectUrl,
@@ -171,14 +126,101 @@ const ConnectedAccountMenu = ({ account }: { account: ExternalAccountResource })
171126
}
172127
};
173128

129+
const ImageOrInitial = () =>
130+
providerToDisplayData[account.provider].iconUrl ? (
131+
<Image
132+
elementDescriptor={[descriptors.providerIcon]}
133+
elementId={descriptors.socialButtonsProviderIcon.setId(account.provider)}
134+
alt={providerToDisplayData[account.provider].name}
135+
src={providerToDisplayData[account.provider].iconUrl}
136+
sx={theme => ({ width: theme.sizes.$4, flexShrink: 0 })}
137+
/>
138+
) : (
139+
<ProviderInitialIcon
140+
id={account.provider}
141+
value={providerToDisplayData[account.provider].name}
142+
/>
143+
);
144+
145+
return (
146+
<Action.Root key={account.id}>
147+
<ProfileSection.Item id='connectedAccounts'>
148+
<Flex sx={t => ({ overflow: 'hidden', gap: t.space.$2 })}>
149+
<ImageOrInitial />
150+
<Box sx={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
151+
<Flex
152+
gap={2}
153+
center
154+
>
155+
<Text sx={t => ({ color: t.colors.$colorText })}>{`${
156+
providerToDisplayData[account.provider].name
157+
}`}</Text>
158+
<Text
159+
truncate
160+
as='span'
161+
colorScheme='secondary'
162+
>
163+
{label ? `• ${label}` : ''}
164+
</Text>
165+
</Flex>
166+
</Box>
167+
</Flex>
168+
169+
<ConnectedAccountMenu />
170+
</ProfileSection.Item>
171+
{shouldDisplayReconnect && (
172+
<Box
173+
sx={t => ({
174+
padding: `${t.sizes.$none} ${t.sizes.$none} ${t.sizes.$1x5} ${t.sizes.$8x5}`,
175+
})}
176+
>
177+
<Text
178+
colorScheme='secondary'
179+
sx={t => ({
180+
paddingRight: t.sizes.$1x5,
181+
display: 'inline-block',
182+
})}
183+
localizationKey={connectedAccountErrorMessage}
184+
/>
185+
186+
<Button
187+
sx={{
188+
display: 'inline-block',
189+
}}
190+
onClick={reconnect}
191+
variant='link'
192+
localizationKey={localizationKeys(
193+
'userProfile.start.connectedAccountsSection.actionLabel__connectionFailed',
194+
)}
195+
/>
196+
</Box>
197+
)}
198+
199+
{account.verification?.error?.code && !shouldDisplayReconnect && (
200+
<Text
201+
colorScheme='danger'
202+
sx={t => ({
203+
padding: `${t.sizes.$none} ${t.sizes.$1x5} ${t.sizes.$1x5} ${t.sizes.$8x5}`,
204+
})}
205+
>
206+
{fallbackErrorMessage}
207+
</Text>
208+
)}
209+
210+
<Action.Open value='remove'>
211+
<Action.Card variant='destructive'>
212+
<RemoveConnectedAccountScreen accountId={account.id} />
213+
</Action.Card>
214+
</Action.Open>
215+
</Action.Root>
216+
);
217+
};
218+
219+
const ConnectedAccountMenu = () => {
220+
const { open } = useActionContext();
221+
174222
const actions = (
175223
[
176-
error || reauthorizationRequired
177-
? {
178-
label: actionLabel,
179-
onClick: handleOnClick,
180-
}
181-
: null,
182224
{
183225
label: localizationKeys('userProfile.start.connectedAccountsSection.destructiveActionTitle'),
184226
isDestructive: true,

0 commit comments

Comments
 (0)