Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(clerk-js,localizations): Reconnect to enterprise account on verification error #4598

Closed
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
5 changes: 5 additions & 0 deletions .changeset/dull-penguins-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/types': minor
---

- Introduced `subtitle__disconnected` and `actionLabel__connectionFailed` under `userProfile.start.enterpriseAccountsSection`
5 changes: 5 additions & 0 deletions .changeset/itchy-mangos-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

Allows enterprise accounts to reconnect on OAuth verification errors, improving the previous UX which just showed a "Requires action" badge
5 changes: 5 additions & 0 deletions .changeset/kind-kids-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/localizations': minor
---

- Introduced `subtitle__disconnected` and `actionLabel__connectionFailed` under `userProfile.start.enterpriseAccountsSection`
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { PropsOfComponent } from '../../styledSystem';
import { handleError } from '../../utils';
import { AddConnectedAccount } from './ConnectedAccountsMenu';
import { RemoveConnectedAccountForm } from './RemoveResourceForm';
import { errorCodesForReconnect } from './utils';

type RemoveConnectedAccountScreenProps = { accountId: string };
const RemoveConnectedAccountScreen = (props: RemoveConnectedAccountScreenProps) => {
Expand All @@ -27,25 +28,6 @@ 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(
({ shouldAllowCreation = true }: { shouldAllowCreation?: boolean }) => {
const { user } = useUser();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import { useUser } from '@clerk/shared/react';
import type { EnterpriseAccountResource, OAuthProvider } from '@clerk/types';

import { ProviderInitialIcon } from '../../common';
import { Badge, Box, descriptors, Flex, Image, localizationKeys, Text } from '../../customizables';
import { ProfileSection } from '../../elements';
import { Box, Button, descriptors, Flex, Image, localizationKeys, Text } from '../../customizables';
import { ProfileSection, useCardState, withCardStateProvider } from '../../elements';
import { Action } from '../../elements/Action';
import { useRouter } from '../../router';
import { handleError } from '../../utils';
import { errorCodesForReconnect } from './utils';

export const EnterpriseAccountsSection = () => {
export const EnterpriseAccountsSection = withCardStateProvider(() => {
const { user } = useUser();

const activeEnterpriseAccounts = user?.enterpriseAccounts.filter(
Expand All @@ -33,50 +37,99 @@ export const EnterpriseAccountsSection = () => {
</ProfileSection.ItemList>
</ProfileSection.Root>
);
};
});

const EnterpriseAccount = ({ account }: { account: EnterpriseAccountResource }) => {
const label = account.emailAddress;
const card = useCardState();
const { navigate } = useRouter();

const reconnect = async () => {
try {
await navigate(account.verification!.externalVerificationRedirectURL?.href || '');
} catch (err) {
handleError(err, [], card.setError);
}
};

const shouldDisplayReconnect = errorCodesForReconnect.includes(account.verification?.error?.code || '');
const fallbackErrorMessage = account.verification?.error?.longMessage;
const reconnectAccountErrorMessage = shouldDisplayReconnect
? localizationKeys(`userProfile.start.enterpriseAccountsSection.subtitle__disconnected`)
: fallbackErrorMessage;
const connectionName = account?.enterpriseConnection?.name;
const error = account.verification?.error?.longMessage;
const label = account.emailAddress;

return (
<ProfileSection.Item
id='enterpriseAccounts'
sx={t => ({
gap: t.space.$2,
justifyContent: 'start',
})}
key={account.id}
>
<EnterpriseAccountProviderIcon account={account} />
<Box sx={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<Flex
gap={2}
center
>
<Text
truncate
colorScheme='body'
<Action.Root key={account.id}>
<ProfileSection.Item
id='enterpriseAccounts'
sx={t => ({
gap: t.space.$2,
justifyContent: 'start',
})}
>
<EnterpriseAccountProviderIcon account={account} />
<Box sx={{ whiteSpace: 'nowrap', overflow: 'hidden' }}>
<Flex
gap={2}
center
>
{connectionName}
</Text>
<Text
truncate
colorScheme='body'
>
{connectionName}
</Text>
<Text
truncate
as='span'
colorScheme='secondary'
>
{label ? `• ${label}` : ''}
</Text>
</Flex>
</Box>
</ProfileSection.Item>

{shouldDisplayReconnect && (
<Box
sx={t => ({
padding: `${t.sizes.$none} ${t.sizes.$none} ${t.sizes.$1x5} ${t.sizes.$8x5}`,
})}
>
<Text
truncate
as='span'
colorScheme='secondary'
>
{label ? `• ${label}` : ''}
</Text>
{error && (
<Badge
colorScheme='danger'
localizationKey={localizationKeys('badge__requiresAction')}
/>
)}
</Flex>
</Box>
</ProfileSection.Item>
sx={t => ({
paddingRight: t.sizes.$1x5,
display: 'inline-block',
})}
localizationKey={reconnectAccountErrorMessage}
/>

<Button
sx={{
display: 'inline-block',
}}
onClick={reconnect}
variant='link'
localizationKey={localizationKeys(
'userProfile.start.enterpriseAccountsSection.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.Root>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ClerkAPIErrorJSON } from '@clerk/types';
import { describe, it } from '@jest/globals';
import React from 'react';

Expand Down Expand Up @@ -51,11 +52,7 @@ const withInactiveEnterpriseConnection = createFixtures.config(f => {
strategy: 'saml',
verified_at_client: 'foo',
attempts: 0,
error: {
code: 'identifier_already_signed_in',
long_message: "You're already signed in",
message: "You're already signed in",
},
error: {} as ClerkAPIErrorJSON,
expire_at: 123,
id: 'ver_123',
object: 'verification',
Expand Down Expand Up @@ -100,11 +97,7 @@ const withOAuthBuiltInEnterpriseConnection = createFixtures.config(f => {
strategy: 'oauth_google',
verified_at_client: 'foo',
attempts: 0,
error: {
code: 'identifier_already_signed_in',
long_message: "You're already signed in",
message: "You're already signed in",
},
error: {} as ClerkAPIErrorJSON,
expire_at: 123,
id: 'ver_123',
object: 'verification',
Expand Down Expand Up @@ -214,6 +207,54 @@ const withSamlEnterpriseConnection = createFixtures.config(f => {
});
});

const withReconnectableConnection = createFixtures.config(f => {
f.withUser({
enterprise_accounts: [
{
object: 'enterprise_account',
active: true,
first_name: 'Laura',
last_name: 'Serafim',
protocol: 'oauth',
provider_user_id: null,
public_metadata: {},
email_address: '[email protected]',
provider: 'oauth_google',
enterprise_connection: {
object: 'enterprise_connection',
provider: 'oauth_google',
name: 'Google',
id: 'ent_123',
active: true,
allow_idp_initiated: false,
allow_subdomains: false,
disable_additional_identifications: false,
sync_user_attributes: false,
domain: 'foocorp.com',
created_at: 123,
updated_at: 123,
logo_public_url: 'https://img.clerk.com/static/google.svg',
protocol: 'oauth',
},
verification: {
status: 'failed',
strategy: 'oauth_google',
verified_at_client: 'foo',
attempts: 0,
error: {
code: 'external_account_missing_refresh_token',
} as ClerkAPIErrorJSON,
expire_at: 123,
id: 'ver_123',
object: 'verification',
external_verification_redirect_url: 'https://foo.com/oauth',
},
id: 'eac_123',
},
],
});
});

describe('EnterpriseAccountsSection ', () => {
describe('without enterprise accounts', () => {
it('does not render the component', async () => {
Expand Down Expand Up @@ -293,4 +334,36 @@ describe('EnterpriseAccountsSection ', () => {
getByText(/[email protected]/i);
});
});

describe('with verification error', () => {
it('allows to reconnect', async () => {
const { wrapper } = await createFixtures(withReconnectableConnection);

const { userEvent, getByText, getByRole } = render(<EnterpriseAccountsSection />, { wrapper });

getByText(/^Enterprise accounts/i);
getByText(/google/i);
const img = getByRole('img', { name: /google/i });
expect(img.getAttribute('src')).toBe('https://img.clerk.com/static/google.svg?width=160');
getByText(/[email protected]/i);
getByText('This account has been disconnected.');
getByRole('button', { name: /reconnect/i });
await userEvent.click(getByRole('button', { name: /reconnect/i }));
});
});

describe('without verification error', () => {
it('does not display the ability to reconnect', async () => {
const { wrapper } = await createFixtures(withOAuthBuiltInEnterpriseConnection);

const { getByText, getByRole, queryByText } = render(<EnterpriseAccountsSection />, { wrapper });

getByText(/^Enterprise accounts/i);
getByText(/google/i);
const img = getByRole('img', { name: /google/i });
expect(img.getAttribute('src')).toBe('https://img.clerk.com/static/google.svg?width=160');
getByText(/[email protected]/i);
expect(queryByText(/^This account has been disconnected/i)).not.toBeInTheDocument();
});
});
});
22 changes: 22 additions & 0 deletions packages/clerk-js/src/ui/components/UserProfile/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,25 @@ export function sortIdentificationBasedOnVerification<T extends Array<EmailAddre

return [...primaryItem, ...verifiedItems, ...unverifiedItems, ...unverifiedItemsWithoutVerification] as T;
}

/**
* Error codes that indicate a need for reconnection during OAuth verification.
*/
export 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',
];
2 changes: 2 additions & 0 deletions packages/localizations/src/ar-SA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,8 @@ export const arSA: LocalizationResource = {
title: 'العنوان الإلكتروني',
},
enterpriseAccountsSection: {
actionLabel__connectionFailed: undefined,
subtitle__disconnected: undefined,
title: 'حساب المؤسسات',
},
headerTitle__account: 'الحساب',
Expand Down
2 changes: 2 additions & 0 deletions packages/localizations/src/be-BY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,8 @@ export const beBY: LocalizationResource = {
title: 'Адрасы электроннай пошты',
},
enterpriseAccountsSection: {
actionLabel__connectionFailed: undefined,
subtitle__disconnected: undefined,
title: 'Enterprise accounts',
},
headerTitle__account: 'Уліковы запіс',
Expand Down
2 changes: 2 additions & 0 deletions packages/localizations/src/bg-BG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,8 @@ export const bgBG: LocalizationResource = {
title: 'Имейл адреси',
},
enterpriseAccountsSection: {
actionLabel__connectionFailed: undefined,
subtitle__disconnected: undefined,
title: 'Корпоративни акаунти',
},
headerTitle__account: 'Профил',
Expand Down
2 changes: 2 additions & 0 deletions packages/localizations/src/cs-CZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,8 @@ export const csCZ: LocalizationResource = {
title: 'Emailové adresy',
},
enterpriseAccountsSection: {
actionLabel__connectionFailed: undefined,
subtitle__disconnected: undefined,
title: 'Enterprise accounts',
},
headerTitle__account: 'Účet',
Expand Down
2 changes: 2 additions & 0 deletions packages/localizations/src/da-DK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,8 @@ export const daDK: LocalizationResource = {
title: 'E-mailadresser',
},
enterpriseAccountsSection: {
actionLabel__connectionFailed: undefined,
subtitle__disconnected: undefined,
title: 'Virksomhedskonti',
},
headerTitle__account: 'Konto',
Expand Down
Loading
Loading