Skip to content

Commit

Permalink
fix(clerk-js): Ensure only one action is present in UserProfile secti…
Browse files Browse the repository at this point in the history
…ons (#5030)
  • Loading branch information
alexcarpenter authored Feb 5, 2025
1 parent 5c26eef commit 68b8917
Show file tree
Hide file tree
Showing 16 changed files with 445 additions and 164 deletions.
5 changes: 5 additions & 0 deletions .changeset/seven-games-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Ensure only one action is open per section within `UserProfile`.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useEnabledThirdPartyProviders } from '../../hooks';
import { useRouter } from '../../router';
import { handleError, sleep } from '../../utils';

const ConnectMenuButton = (props: { strategy: OAuthStrategy }) => {
const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => void }) => {
const { strategy } = props;
const card = useCardState();
const { user } = useUser();
Expand Down Expand Up @@ -95,7 +95,7 @@ const ConnectMenuButton = (props: { strategy: OAuthStrategy }) => {
);
};

export const AddConnectedAccount = () => {
export const AddConnectedAccount = ({ onClick }: { onClick?: () => void }) => {
const { user } = useUser();
const { strategies } = useEnabledThirdPartyProviders();

Expand All @@ -114,6 +114,7 @@ export const AddConnectedAccount = () => {
<ProfileSection.ActionMenu
triggerLocalizationKey={localizationKeys('userProfile.start.connectedAccountsSection.primaryButton')}
id='connectedAccounts'
onClick={onClick}
>
{unconnectedStrategies.map(strategy => (
<ConnectMenuButton
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useReverification, useUser } from '@clerk/shared/react';
import type { ExternalAccountResource, OAuthProvider, OAuthScope, OAuthStrategy } from '@clerk/types';
import { Fragment, useState } from 'react';

import { appendModalState } from '../../../utils';
import { ProviderInitialIcon } from '../../common';
Expand Down Expand Up @@ -51,6 +52,7 @@ export const ConnectedAccountsSection = withCardStateProvider(
const { user } = useUser();
const card = useCardState();
const hasExternalAccounts = Boolean(user?.externalAccounts?.length);
const [actionValue, setActionValue] = useState<string | null>(null);

if (!user || (!shouldAllowCreation && !hasExternalAccounts)) {
return null;
Expand All @@ -68,7 +70,10 @@ export const ConnectedAccountsSection = withCardStateProvider(
id='connectedAccounts'
>
<Card.Alert>{card.error}</Card.Alert>
<Action.Root>
<Action.Root
value={actionValue}
onChange={setActionValue}
>
<ProfileSection.ItemList id='connectedAccounts'>
{accounts.map(account => (
<ConnectedAccount
Expand All @@ -77,7 +82,7 @@ export const ConnectedAccountsSection = withCardStateProvider(
/>
))}
</ProfileSection.ItemList>
{shouldAllowCreation && <AddConnectedAccount />}
{shouldAllowCreation && <AddConnectedAccount onClick={() => setActionValue(null)} />}
</Action.Root>
</ProfileSection.Root>
);
Expand All @@ -89,6 +94,7 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) =>
const { navigate } = useRouter();
const { user } = useUser();
const card = useCardState();
const accountId = account.id;

const isModal = mode === 'modal';
const redirectUrl = isModal
Expand Down Expand Up @@ -158,7 +164,7 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) =>
);

return (
<Action.Root key={account.id}>
<Fragment key={account.id}>
<ProfileSection.Item id='connectedAccounts'>
<Flex sx={t => ({ overflow: 'hidden', gap: t.space.$2 })}>
<ImageOrInitial />
Expand All @@ -181,7 +187,7 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) =>
</Box>
</Flex>

<ConnectedAccountMenu />
<ConnectedAccountMenu account={account} />
</ProfileSection.Item>
{shouldDisplayReconnect && (
<Box
Expand Down Expand Up @@ -222,24 +228,25 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) =>
</Text>
)}

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

const ConnectedAccountMenu = () => {
const ConnectedAccountMenu = ({ account }: { account: ExternalAccountResource }) => {
const { open } = useActionContext();
const accountId = account.id;

const actions = (
[
{
label: localizationKeys('userProfile.start.connectedAccountsSection.destructiveActionTitle'),
isDestructive: true,
onClick: () => open('remove'),
onClick: () => open(`remove-${accountId}`),
},
] satisfies (PropsOfComponent<typeof ThreeDotsMenu>['actions'][0] | null)[]
).filter(a => a !== null) as PropsOfComponent<typeof ThreeDotsMenu>['actions'];
Expand Down
77 changes: 41 additions & 36 deletions packages/clerk-js/src/ui/components/UserProfile/EmailsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useUser } from '@clerk/shared/react';
import type { EmailAddressResource } from '@clerk/types';
import { Fragment } from 'react';

import { sortIdentificationBasedOnVerification } from '../../components/UserProfile/utils';
import { Badge, Flex, localizationKeys, Text } from '../../customizables';
Expand Down Expand Up @@ -46,39 +47,42 @@ export const EmailsSection = ({ shouldAllowCreation = true }) => {
>
<Action.Root>
<ProfileSection.ItemList id='emailAddresses'>
{sortIdentificationBasedOnVerification(user?.emailAddresses, user?.primaryEmailAddressId).map(email => (
<Action.Root key={email.emailAddress}>
<ProfileSection.Item id='emailAddresses'>
<Flex sx={t => ({ overflow: 'hidden', gap: t.space.$1 })}>
<Text
sx={t => ({ color: t.colors.$colorText })}
truncate
>
{email.emailAddress}
</Text>
{user?.primaryEmailAddressId === email.id && (
<Badge localizationKey={localizationKeys('badge__primary')} />
)}
{email.verification.status !== 'verified' && (
<Badge localizationKey={localizationKeys('badge__unverified')} />
)}
</Flex>
<EmailMenu email={email} />
</ProfileSection.Item>
{sortIdentificationBasedOnVerification(user?.emailAddresses, user?.primaryEmailAddressId).map(email => {
const emailId = email.id;
return (
<Fragment key={email.emailAddress}>
<ProfileSection.Item id='emailAddresses'>
<Flex sx={t => ({ overflow: 'hidden', gap: t.space.$1 })}>
<Text
sx={t => ({ color: t.colors.$colorText })}
truncate
>
{email.emailAddress}
</Text>
{user?.primaryEmailAddressId === emailId && (
<Badge localizationKey={localizationKeys('badge__primary')} />
)}
{email.verification.status !== 'verified' && (
<Badge localizationKey={localizationKeys('badge__unverified')} />
)}
</Flex>
<EmailMenu email={email} />
</ProfileSection.Item>

<Action.Open value='remove'>
<Action.Card variant='destructive'>
<RemoveEmailScreen emailId={email.id} />
</Action.Card>
</Action.Open>
<Action.Open value={`remove-${emailId}`}>
<Action.Card variant='destructive'>
<RemoveEmailScreen emailId={emailId} />
</Action.Card>
</Action.Open>

<Action.Open value='verify'>
<Action.Card>
<EmailScreen emailId={email.id} />
</Action.Card>
</Action.Open>
</Action.Root>
))}
<Action.Open value={`verify-${emailId}`}>
<Action.Card>
<EmailScreen emailId={emailId} />
</Action.Card>
</Action.Open>
</Fragment>
);
})}
{shouldAllowCreation && (
<>
<Action.Trigger value='add'>
Expand All @@ -104,18 +108,19 @@ const EmailMenu = ({ email }: { email: EmailAddressResource }) => {
const card = useCardState();
const { user } = useUser();
const { open } = useActionContext();
const isPrimary = user?.primaryEmailAddressId === email.id;
const emailId = email.id;
const isPrimary = user?.primaryEmailAddressId === emailId;
const isVerified = email.verification.status === 'verified';
const setPrimary = () => {
return user?.update({ primaryEmailAddressId: email.id }).catch(e => handleError(e, [], card.setError));
return user?.update({ primaryEmailAddressId: emailId }).catch(e => handleError(e, [], card.setError));
};

const actions = (
[
isPrimary && !isVerified
? {
label: localizationKeys('userProfile.start.emailAddressesSection.detailsAction__primary'),
onClick: () => open('verify'),
onClick: () => open(`verify-${emailId}`),
}
: null,
!isPrimary && isVerified
Expand All @@ -127,13 +132,13 @@ const EmailMenu = ({ email }: { email: EmailAddressResource }) => {
!isPrimary && !isVerified
? {
label: localizationKeys('userProfile.start.emailAddressesSection.detailsAction__unverified'),
onClick: () => open('verify'),
onClick: () => open(`verify-${emailId}`),
}
: null,
{
label: localizationKeys('userProfile.start.emailAddressesSection.destructiveAction'),
isDestructive: true,
onClick: () => open('remove'),
onClick: () => open(`remove-${emailId}`),
},
] satisfies (PropsOfComponent<typeof ThreeDotsMenu>['actions'][0] | null)[]
).filter(a => a !== null) as PropsOfComponent<typeof ThreeDotsMenu>['actions'];
Expand Down
41 changes: 26 additions & 15 deletions packages/clerk-js/src/ui/components/UserProfile/MfaSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useUser } from '@clerk/shared/react';
import type { PhoneNumberResource, VerificationStrategy } from '@clerk/types';
import React, { useState } from 'react';
import React, { Fragment, useState } from 'react';

import { useEnvironment } from '../../contexts';
import { Badge, Flex, Icon, localizationKeys, Text } from '../../customizables';
Expand All @@ -19,6 +19,7 @@ export const MfaSection = () => {
userSettings: { attributes },
} = useEnvironment();
const { user } = useUser();
const [actionValue, setActionValue] = useState<string | null>(null);

if (!user) {
return null;
Expand All @@ -41,10 +42,13 @@ export const MfaSection = () => {
centered={false}
id='mfa'
>
<Action.Root>
<Action.Root
value={actionValue}
onChange={setActionValue}
>
<ProfileSection.ItemList id='mfa'>
{showTOTP && (
<Action.Root>
<>
<ProfileSection.Item
id='mfa'
hoverable
Expand All @@ -63,19 +67,20 @@ export const MfaSection = () => {
<MfaTOTPMenu />
</ProfileSection.Item>

<Action.Open value='remove'>
<Action.Open value='remove-totp'>
<Action.Card variant='destructive'>
<RemoveMfaTOTPScreen />
</Action.Card>
</Action.Open>
</Action.Root>
</>
)}

{secondFactors.includes('phone_code') &&
mfaPhones.map(phone => {
const isDefault = !showTOTP && phone.defaultSecondFactor;
const phoneId = phone.id;
return (
<Action.Root key={phone.id}>
<Fragment key={phoneId}>
<ProfileSection.Item
id='mfa'
hoverable
Expand All @@ -97,17 +102,17 @@ export const MfaSection = () => {
/>
</ProfileSection.Item>

<Action.Open value='remove'>
<Action.Open value={`remove-${phoneId}`}>
<Action.Card variant='destructive'>
<RemoveMfaPhoneCodeScreen phoneId={phone.id} />
<RemoveMfaPhoneCodeScreen phoneId={phoneId} />
</Action.Card>
</Action.Open>
</Action.Root>
</Fragment>
);
})}

{showBackupCode && (
<Action.Root>
<>
<ProfileSection.Item
id='mfa'
hoverable
Expand All @@ -129,10 +134,13 @@ export const MfaSection = () => {
<MfaBackupCodeCreateScreen />
</Action.Card>
</Action.Open>
</Action.Root>
</>
)}

<MfaAddMenu secondFactorsAvailableToAdd={secondFactorsAvailableToAdd} />
<MfaAddMenu
secondFactorsAvailableToAdd={secondFactorsAvailableToAdd}
onClick={() => setActionValue(null)}
/>
</ProfileSection.ItemList>
</Action.Root>
</ProfileSection.Root>
Expand All @@ -147,6 +155,7 @@ type MfaPhoneCodeMenuProps = {
const MfaPhoneCodeMenu = ({ phone, showTOTP }: MfaPhoneCodeMenuProps) => {
const { open } = useActionContext();
const card = useCardState();
const phoneId = phone.id;

const actions = (
[
Expand All @@ -160,7 +169,7 @@ const MfaPhoneCodeMenu = ({ phone, showTOTP }: MfaPhoneCodeMenuProps) => {
{
label: localizationKeys('userProfile.start.mfaSection.phoneCode.destructiveActionLabel'),
isDestructive: true,
onClick: () => open('remove'),
onClick: () => open(`remove-${phoneId}`),
},
] satisfies (PropsOfComponent<typeof ThreeDotsMenu>['actions'][0] | null)[]
).filter(a => a !== null) as PropsOfComponent<typeof ThreeDotsMenu>['actions'];
Expand Down Expand Up @@ -191,7 +200,7 @@ const MfaTOTPMenu = () => {
{
label: localizationKeys('userProfile.start.mfaSection.totp.destructiveActionTitle'),
isDestructive: true,
onClick: () => open('remove'),
onClick: () => open('remove-totp'),
},
] satisfies (PropsOfComponent<typeof ThreeDotsMenu>['actions'][0] | null)[]
).filter(a => a !== null) as PropsOfComponent<typeof ThreeDotsMenu>['actions'];
Expand All @@ -201,11 +210,12 @@ const MfaTOTPMenu = () => {

type MfaAddMenuProps = ProfileSectionActionMenuItemProps & {
secondFactorsAvailableToAdd: string[];
onClick?: () => void;
};

const MfaAddMenu = (props: MfaAddMenuProps) => {
const { open } = useActionContext();
const { secondFactorsAvailableToAdd } = props;
const { secondFactorsAvailableToAdd, onClick } = props;
const [selectedStrategy, setSelectedStrategy] = useState<VerificationStrategy>();

const strategies = React.useMemo(
Expand Down Expand Up @@ -245,6 +255,7 @@ const MfaAddMenu = (props: MfaAddMenuProps) => {
<ProfileSection.ActionMenu
id='mfa'
triggerLocalizationKey={localizationKeys('userProfile.start.mfaSection.primaryButton')}
onClick={onClick}
>
{strategies.map(
method =>
Expand Down
Loading

0 comments on commit 68b8917

Please sign in to comment.