Skip to content
5 changes: 5 additions & 0 deletions .changeset/sharp-sheep-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/clerk-js": patch
---

fix(clerk-js): Correctly show alternative methods for user re-verification card
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import type { LocalizationKey } from '../../customizables';
import { Col, descriptors, Flex, Flow, localizationKeys } from '../../customizables';
import { ArrowBlockButton, BackLink, Card, Header } from '../../elements';
import { useCardState } from '../../elements/contexts';
import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies';
import { ChatAltIcon, Email, LockClosedIcon } from '../../icons';
import { formatSafeIdentifier } from '../../utils';
import { useReverificationAlternativeStrategies } from './useReverificationAlternativeStrategies';
import { useUserVerificationSession } from './useUserVerificationSession';
import { withHavingTrouble } from './withHavingTrouble';

Expand All @@ -29,7 +29,7 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
const { onBackLinkClick, onHavingTroubleClick, onFactorSelected } = props;
const card = useCardState();
const { data } = useUserVerificationSession();
const { firstPartyFactors, hasAnyStrategy } = useAlternativeStrategies<SessionVerificationFirstFactor>({
const { firstPartyFactors, hasAnyStrategy } = useReverificationAlternativeStrategies<SessionVerificationFirstFactor>({
filterOutFactor: props?.currentFactor,
supportedFirstFactors: data?.supportedFirstFactors,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const UVFactorOnePhoneCodeCard = (props: UVFactorOnePhoneCodeCardProps) =
cardSubtitle={localizationKeys('reverification.phoneCode.subtitle')}
inputLabel={localizationKeys('reverification.phoneCode.formTitle')}
resendButton={localizationKeys('reverification.phoneCode.resendButton')}
showAlternativeMethods={props.showAlternativeMethods}
/>
</Flow.Part>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type UVFactorTwoCodeCard = Pick<VerificationCodeCardProps, 'onShowAlterna
factorAlreadyPrepared: boolean;
onFactorPrepare: () => void;
prepare?: () => Promise<SessionVerificationResource>;
showAlternativeMethods?: boolean;
};

type SignInFactorTwoCodeFormProps = UVFactorTwoCodeCard & {
Expand Down Expand Up @@ -64,6 +65,7 @@ export const UVFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => {
safeIdentifier={'safeIdentifier' in props.factor ? props.factor.safeIdentifier : undefined}
profileImageUrl={session?.user?.imageUrl}
onShowAlternativeMethodsClicked={props.onShowAlternativeMethodsClicked}
showAlternativeMethods={props.showAlternativeMethods}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type { UVFactorTwoCodeCard } from './UVFactorTwoCodeForm';
import { UVFactorTwoCodeForm } from './UVFactorTwoCodeForm';

type UVFactorTwoPhoneCodeCardProps = UVFactorTwoCodeCard & { factor: PhoneCodeFactor };

export const UVFactorTwoPhoneCodeCard = (props: UVFactorTwoPhoneCodeCardProps) => {
const { session } = useSession();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import React, { useEffect } from 'react';

import { useEnvironment } from '../../contexts';
import { ErrorCard, LoadingCard, useCardState, withCardStateProvider } from '../../elements';
import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies';
import { localizationKeys } from '../../localization';
import { useRouter } from '../../router';
import { determineStartingSignInFactor, factorHasLocalStrategy } from '../SignIn/utils';
import { AlternativeMethods } from './AlternativeMethods';
import { useReverificationAlternativeStrategies } from './useReverificationAlternativeStrategies';
import { UserVerificationFactorOnePasswordCard } from './UserVerificationFactorOnePassword';
import { useUserVerificationSession, withUserVerificationSessionGuard } from './useUserVerificationSession';
import { UVFactorOneEmailCodeCard } from './UVFactorOneEmailCodeCard';
Expand Down Expand Up @@ -46,7 +46,7 @@ export function _UserVerificationFactorOne(): JSX.Element | null {
prevCurrentFactor: undefined,
}));

const { hasAnyStrategy } = useAlternativeStrategies({
const { hasAnyStrategy, hasFirstParty } = useReverificationAlternativeStrategies({
filterOutFactor: currentFactor,
supportedFirstFactors: availableFactors,
});
Expand Down Expand Up @@ -116,6 +116,7 @@ export function _UserVerificationFactorOne(): JSX.Element | null {
onFactorPrepare={handleFactorPrepare}
onShowAlternativeMethodsClicked={toggleAllStrategies}
factor={currentFactor}
showAlternativeMethods={hasFirstParty}
/>
);
case 'phone_code':
Expand All @@ -125,6 +126,7 @@ export function _UserVerificationFactorOne(): JSX.Element | null {
onFactorPrepare={handleFactorPrepare}
onShowAlternativeMethodsClicked={toggleAllStrategies}
factor={currentFactor}
showAlternativeMethods={hasFirstParty}
/>
);
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { SessionVerificationResource, SessionVerificationSecondFactor, SignInFactor } from '@clerk/types';
import React, { useEffect } from 'react';
import React, { useEffect, useMemo } from 'react';

import { LoadingCard, withCardStateProvider } from '../../elements';
import { useRouter } from '../../router';
import { determineStartingSignInSecondFactor } from '../SignIn/utils';
import { secondFactorsAreEqual } from './useReverificationAlternativeStrategies';
import { UserVerificationFactorTwoTOTP } from './UserVerificationFactorTwoTOTP';
import { useUserVerificationSession, withUserVerificationSessionGuard } from './useUserVerificationSession';
import { UVFactorTwoAlternativeMethods } from './UVFactorTwoAlternativeMethods';
Expand All @@ -21,7 +22,7 @@ const factorKey = (factor: SignInFactor | null | undefined) => {
return key;
};

export function _UserVerificationFactorTwo(): JSX.Element {
export function UserVerificationFactorTwoComponent(): JSX.Element {
const { navigate } = useRouter();
const { data } = useUserVerificationSession();
const sessionVerification = data as SessionVerificationResource;
Expand All @@ -35,6 +36,11 @@ export function _UserVerificationFactorTwo(): JSX.Element {
const [showAllStrategies, setShowAllStrategies] = React.useState<boolean>(!currentFactor);
const toggleAllStrategies = () => setShowAllStrategies(s => !s);

const secondFactorsExcludingCurrent = useMemo(
() => availableFactors?.filter(factor => !secondFactorsAreEqual(factor, currentFactor)),
[availableFactors, currentFactor],
);

const handleFactorPrepare = () => {
lastPreparedFactorKeyRef.current = factorKey(currentFactor);
};
Expand All @@ -44,20 +50,26 @@ export function _UserVerificationFactorTwo(): JSX.Element {
toggleAllStrategies();
};

const hasAlternativeStrategies = useMemo(
() => (secondFactorsExcludingCurrent && secondFactorsExcludingCurrent.length > 0) || false,
[secondFactorsExcludingCurrent],
);

useEffect(() => {
if (sessionVerification.status === 'needs_first_factor') {
void navigate('../');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

if (!currentFactor) {
return <LoadingCard />;
}

if (showAllStrategies) {
if (showAllStrategies && hasAlternativeStrategies) {
return (
<UVFactorTwoAlternativeMethods
supportedSecondFactors={sessionVerification.supportedSecondFactors}
supportedSecondFactors={secondFactorsExcludingCurrent || null}
onBackLinkClick={toggleAllStrategies}
onFactorSelected={selectFactor}
/>
Expand All @@ -72,6 +84,7 @@ export function _UserVerificationFactorTwo(): JSX.Element {
onFactorPrepare={handleFactorPrepare}
factor={currentFactor}
onShowAlternativeMethodsClicked={toggleAllStrategies}
showAlternativeMethods={hasAlternativeStrategies}
/>
);
case 'totp':
Expand All @@ -81,6 +94,7 @@ export function _UserVerificationFactorTwo(): JSX.Element {
onFactorPrepare={handleFactorPrepare}
factor={currentFactor}
onShowAlternativeMethodsClicked={toggleAllStrategies}
showAlternativeMethods={hasAlternativeStrategies}
/>
);
case 'backup_code':
Expand All @@ -91,5 +105,5 @@ export function _UserVerificationFactorTwo(): JSX.Element {
}

export const UserVerificationFactorTwo = withUserVerificationSessionGuard(
withCardStateProvider(_UserVerificationFactorTwo),
withCardStateProvider(UserVerificationFactorTwoComponent),
);
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function UserVerificationFactorTwoTOTP(props: UVFactorTwoTOTPCardProps):
cardTitle={localizationKeys('reverification.totpMfa.title')}
cardSubtitle={localizationKeys('reverification.totpMfa.subtitle')}
inputLabel={localizationKeys('reverification.totpMfa.formTitle')}
showAlternativeMethods={props.showAlternativeMethods}
/>
</Flow.Part>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,81 @@ describe('UserVerificationFactorOne', () => {
});

describe('Use another method', () => {
it.todo('should list enabled first factor methods without the current one');
it('should list enabled first factor methods without the current one', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ username: 'clerkuser' });
});
fixtures.session?.startVerification.mockResolvedValue({
status: 'needs_first_factor',
supportedFirstFactors: [
{
strategy: 'email_code',
emailAddressId: 'email_1',
safeIdentifier: 'xxx@hello.com',
},
{
strategy: 'email_code',
emailAddressId: 'email_2',
safeIdentifier: 'xxx+1@hello.com',
},
],
});
fixtures.session?.prepareFirstFactorVerification.mockResolvedValue({});

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

await waitFor(() => {
getByText('Verification required');
getByText('Use another method');
});

await waitFor(() => {
getByText('Use another method').click();
expect(getByRole('button')).toHaveTextContent('Email code to xxx+1@hello.com');
expect(getByRole('button')).not.toHaveTextContent('Email code to xxx@hello.com');
});
});

it('can select another method', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ username: 'clerkuser' });
});
fixtures.session?.startVerification.mockResolvedValue({
status: 'needs_first_factor',
supportedFirstFactors: [
{
strategy: 'email_code',
emailAddressId: 'email_1',
safeIdentifier: 'xxx@hello.com',
},
{
strategy: 'email_code',
emailAddressId: 'email_2',
safeIdentifier: 'xxx+1@hello.com',
},
],
});
fixtures.session?.prepareFirstFactorVerification.mockResolvedValue({});

const { getByText, container } = render(<UserVerificationFactorOne />, { wrapper });

await waitFor(() => {
getByText('Verification required');
expect(container).toHaveTextContent('xxx@hello.com');
expect(container).not.toHaveTextContent('xxx+1@hello.com');
getByText('Use another method');
});

await waitFor(() => {
getByText('Use another method').click();
getByText('Email code to xxx+1@hello.com').click();
});

await waitFor(() => {
getByText('Verification required');
expect(container).toHaveTextContent('xxx+1@hello.com');
});
});
});

describe('Get Help', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,88 @@ describe('UserVerificationFactorTwo', () => {
});
});

describe('Use another method', () => {
it.todo('should list enabled second factor methods without the current one');
describe('Use another second factor method', () => {
it('should list enabled second factor methods without the current one', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ username: 'clerkuser' });
});
fixtures.session?.startVerification.mockResolvedValue({
status: 'needs_second_factor',
supportedSecondFactors: [
{
strategy: 'phone_code',
phoneNumberId: 'phone_1',
safeIdentifier: '+3069XXXXXXX1',
},
{
strategy: 'phone_code',
phoneNumberId: 'phone_2',
safeIdentifier: '+3069XXXXXXX2',
},
],
});
fixtures.session?.prepareSecondFactorVerification.mockResolvedValue({});

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

await waitFor(() => {
getByText('Verification required');
getByText('Use another method');
});

await waitFor(() => {
getByText('Use another method').click();
expect(getByRole('button')).toHaveTextContent('Send SMS code to +3069XXXXXXX2');
expect(getByRole('button')).not.toHaveTextContent('Send SMS code to +3069XXXXXXX1');
});
});

it('can select another method', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ username: 'clerkuser' });
});
fixtures.session?.startVerification.mockResolvedValue({
status: 'needs_second_factor',
supportedSecondFactors: [
{
strategy: 'phone_code',
phoneNumberId: 'phone_1',
safeIdentifier: '+3069XXXXXXX1',
},
{
strategy: 'phone_code',
phoneNumberId: 'phone_2',
safeIdentifier: '+3069XXXXXXX2',
},
],
});
fixtures.session?.prepareSecondFactorVerification.mockResolvedValue({});

const { getByText, container } = render(<UserVerificationFactorTwo />, { wrapper });

await waitFor(() => {
getByText('Verification required');
expect(container).toHaveTextContent('+3069XXXXXXX1');
expect(container).not.toHaveTextContent('+3069XXXXXXX2');
getByText('Use another method');
});

await waitFor(() => {
getByText('Use another method').click();
getByText('Send SMS code to +3069XXXXXXX2').click();
});

await waitFor(() => {
getByText('Verification required');
expect(container).toHaveTextContent('+3069XXXXXXX2');
getByText('Use another method');
});

await waitFor(() => {
getByText('Use another method').click();
expect(container).toHaveTextContent('+3069XXXXXXX1');
});
});
});

describe('Get Help', () => {
Expand Down
Loading