Skip to content

Commit e8d21de

Browse files
feat(clerk-js,localizations,types): Last authentication strategy (#6722)
Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
1 parent 56a9eb6 commit e8d21de

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+819
-474
lines changed

.changeset/neat-women-love.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/localizations': patch
4+
'@clerk/types': patch
5+
---
6+
7+
Introduce "Last Used" functionality to Sign In and Up
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type { Page } from '@playwright/test';
2+
import { expect, test } from '@playwright/test';
3+
4+
import type { LastAuthenticationStrategy } from '../../packages/types';
5+
import { appConfigs } from '../presets';
6+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
7+
8+
const mockLastAuthenticationStrategyResponse = async (
9+
page: Page,
10+
lastAuthenticationStrategy: LastAuthenticationStrategy | null | undefined,
11+
) => {
12+
await page.route('**/v1/client?**', async route => {
13+
const response = await route.fetch();
14+
const json = await response.json();
15+
let modifiedJson = json;
16+
17+
if (lastAuthenticationStrategy === undefined && json.response.last_authentication_strategy) {
18+
delete json.response['last_authentication_strategy'];
19+
modifiedJson = json;
20+
} else {
21+
modifiedJson = {
22+
...json,
23+
response: {
24+
...json.response,
25+
last_authentication_strategy: lastAuthenticationStrategy,
26+
},
27+
};
28+
}
29+
30+
await route.fulfill({ response, json: modifiedJson });
31+
});
32+
};
33+
34+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
35+
'lastAuthenticationStrategy @generic',
36+
({ app }) => {
37+
test.afterAll(async () => {
38+
await app.teardown();
39+
});
40+
41+
// TODO(Tom): Remove once the API-side of this feature is fully released. [2025-09-07]
42+
test('should not show "Last used" badge when lastAuthenticationStrategy is not present', async ({
43+
page,
44+
context,
45+
}) => {
46+
const u = createTestUtils({ app, page, context });
47+
await mockLastAuthenticationStrategyResponse(page, undefined);
48+
49+
await u.po.signIn.goTo();
50+
await u.po.signIn.waitForMounted();
51+
52+
// Ensure no "Last used" badge is present.
53+
await expect(page.locator('.cl-lastAuthenticationStrategyBadge')).toHaveCount(0);
54+
55+
// Ensure none of the social buttons have been pulled to the first row.
56+
const socialButtonContainers = u.page.locator('.cl-socialButtons');
57+
await expect(socialButtonContainers).toHaveCount(1);
58+
await expect(socialButtonContainers.first().locator('.cl-button')).toHaveCount(3);
59+
});
60+
61+
test('should not show "Last used" badge when lastAuthenticationStrategy is null', async ({ page, context }) => {
62+
const u = createTestUtils({ app, page, context });
63+
await mockLastAuthenticationStrategyResponse(page, null);
64+
65+
await u.po.signIn.goTo();
66+
await u.po.signIn.waitForMounted();
67+
68+
// Ensure no "Last used" badge is present.
69+
await expect(page.locator('.cl-lastAuthenticationStrategyBadge')).toHaveCount(0);
70+
71+
// Ensure none of the social buttons have been pulled to the first row.
72+
const socialButtonContainers = u.page.locator('.cl-socialButtons');
73+
await expect(socialButtonContainers).toHaveCount(1);
74+
await expect(socialButtonContainers.first().locator('.cl-button')).toHaveCount(3);
75+
});
76+
77+
test('should show "Last used" badge when lastAuthenticationStrategy is oauth_google', async ({ page, context }) => {
78+
const u = createTestUtils({ app, page, context });
79+
await mockLastAuthenticationStrategyResponse(page, 'oauth_google');
80+
81+
await u.po.signIn.goTo();
82+
await u.po.signIn.waitForMounted();
83+
84+
// Ensure "Last used" badge is present.
85+
const lastUsedBadge = page.locator('.cl-lastAuthenticationStrategyBadge');
86+
await expect(lastUsedBadge).toBeVisible();
87+
await expect(lastUsedBadge).toHaveCount(1);
88+
89+
const btn = page.getByRole('button', { name: 'Last used Sign in with Google' });
90+
await expect(btn).toBeVisible();
91+
92+
// Ensure the last used social button has been pulled to the first row.
93+
const socialButtonContainers = u.page.locator('.cl-socialButtons');
94+
await expect(socialButtonContainers).toHaveCount(2);
95+
await expect(socialButtonContainers.first().locator('.cl-button__google')).toHaveCount(1);
96+
await expect(socialButtonContainers.last().locator('.cl-button')).toHaveCount(2);
97+
});
98+
99+
test('should show "Last used" badge when lastAuthenticationStrategy is email_address and identifier is toggleable', async ({
100+
page,
101+
context,
102+
}) => {
103+
const u = createTestUtils({ app, page, context });
104+
await mockLastAuthenticationStrategyResponse(page, 'email_address');
105+
106+
await u.po.signIn.goTo();
107+
await u.po.signIn.waitForMounted();
108+
109+
// Ensure "Last used" badge is not present.
110+
const lastUsedBadge = page.locator('.cl-lastAuthenticationStrategyBadge');
111+
await expect(lastUsedBadge).toHaveCount(0);
112+
113+
// Ensure none of the social buttons have been pulled to the first row.
114+
const socialButtonContainers = u.page.locator('.cl-socialButtons');
115+
await expect(socialButtonContainers).toHaveCount(1);
116+
await expect(socialButtonContainers.first().locator('.cl-button')).toHaveCount(3);
117+
});
118+
},
119+
);

packages/clerk-js/bundlewatch.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "818KB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "819KB" },
44
{ "path": "./dist/clerk.browser.js", "maxSize": "78KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "120KB" },
66
{ "path": "./dist/clerk.headless*.js", "maxSize": "61KB" },
77
{ "path": "./dist/ui-common*.js", "maxSize": "117.1KB" },
8-
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" },
8+
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "120KB" },
99
{ "path": "./dist/vendors*.js", "maxSize": "45KB" },
1010
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },
1111
{ "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" },

packages/clerk-js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@
9494
"@rsdoctor/rspack-plugin": "^0.4.13",
9595
"@rspack/cli": "^1.4.11",
9696
"@rspack/core": "^1.4.11",
97-
"@rspack/plugin-react-refresh": "^1.4.3",
97+
"@rspack/plugin-react-refresh": "^1.5.0",
9898
"@svgr/webpack": "^6.5.1",
9999
"@swc/jest": "0.2.39",
100100
"@types/cloudflare-turnstile": "^0.2.2",

packages/clerk-js/src/core/resources/Client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
ClientJSON,
44
ClientJSONSnapshot,
55
ClientResource,
6+
LastAuthenticationStrategy,
67
SignedInSessionResource,
78
SignInResource,
89
SignUpResource,
@@ -23,6 +24,8 @@ export class Client extends BaseResource implements ClientResource {
2324
lastActiveSessionId: string | null = null;
2425
captchaBypass = false;
2526
cookieExpiresAt: Date | null = null;
27+
/** Last authentication strategy used by this client; `null` when unknown/disabled. */
28+
lastAuthenticationStrategy: LastAuthenticationStrategy | null = null;
2629
createdAt: Date | null = null;
2730
updatedAt: Date | null = null;
2831

@@ -82,6 +85,7 @@ export class Client extends BaseResource implements ClientResource {
8285
this.signUp = new SignUp(null);
8386
this.signIn = new SignIn(null);
8487
this.lastActiveSessionId = null;
88+
this.lastAuthenticationStrategy = null;
8589
this.cookieExpiresAt = null;
8690
this.createdAt = null;
8791
this.updatedAt = null;
@@ -130,6 +134,7 @@ export class Client extends BaseResource implements ClientResource {
130134
this.lastActiveSessionId = data.last_active_session_id;
131135
this.captchaBypass = data.captcha_bypass || false;
132136
this.cookieExpiresAt = data.cookie_expires_at ? unixEpochToDate(data.cookie_expires_at) : null;
137+
this.lastAuthenticationStrategy = data.last_authentication_strategy || null;
133138
this.createdAt = unixEpochToDate(data.created_at || undefined);
134139
this.updatedAt = unixEpochToDate(data.updated_at || undefined);
135140
}
@@ -147,6 +152,7 @@ export class Client extends BaseResource implements ClientResource {
147152
last_active_session_id: this.lastActiveSessionId,
148153
captcha_bypass: this.captchaBypass,
149154
cookie_expires_at: this.cookieExpiresAt ? this.cookieExpiresAt.getTime() : null,
155+
last_authentication_strategy: this.lastAuthenticationStrategy ?? null,
150156
created_at: this.createdAt?.getTime() ?? null,
151157
updated_at: this.updatedAt?.getTime() ?? null,
152158
};

packages/clerk-js/src/ui/common/constants.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Attribute } from '@clerk/types';
1+
import type { Attribute, LastAuthenticationStrategy } from '@clerk/types';
22

33
import type { LocalizationKey } from '../localization/localizationKeys';
44
import { localizationKeys } from '../localization/localizationKeys';
@@ -8,37 +8,54 @@ type FirstFactorConfig = {
88
type: string;
99
placeholder: string | LocalizationKey;
1010
action?: string | LocalizationKey;
11+
validLastAuthenticationStrategies: ReadonlySet<LastAuthenticationStrategy>;
1112
};
1213
const FirstFactorConfigs = Object.freeze({
1314
email_address_username: {
1415
label: localizationKeys('formFieldLabel__emailAddress_username'),
1516
placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress_username'),
1617
type: 'text',
1718
action: localizationKeys('signIn.start.actionLink__use_email_username'),
19+
validLastAuthenticationStrategies: new Set<LastAuthenticationStrategy>([
20+
'email_code',
21+
'email_link',
22+
'email_address',
23+
'username',
24+
'password',
25+
]),
1826
},
1927
email_address: {
2028
label: localizationKeys('formFieldLabel__emailAddress'),
2129
placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress'),
2230
type: 'email',
2331
action: localizationKeys('signIn.start.actionLink__use_email'),
32+
validLastAuthenticationStrategies: new Set<LastAuthenticationStrategy>([
33+
'email_code',
34+
'email_link',
35+
'email_address',
36+
'password',
37+
]),
2438
},
2539
phone_number: {
2640
label: localizationKeys('formFieldLabel__phoneNumber'),
2741
placeholder: localizationKeys('formFieldInputPlaceholder__phoneNumber'),
2842
type: 'tel',
2943
action: localizationKeys('signIn.start.actionLink__use_phone'),
44+
validLastAuthenticationStrategies: new Set<LastAuthenticationStrategy>(['phone_code', 'password']),
3045
},
3146
username: {
3247
label: localizationKeys('formFieldLabel__username'),
3348
placeholder: localizationKeys('formFieldInputPlaceholder__username'),
3449
type: 'text',
3550
action: localizationKeys('signIn.start.actionLink__use_username'),
51+
validLastAuthenticationStrategies: new Set<LastAuthenticationStrategy>(['username', 'password']),
3652
},
3753
default: {
3854
label: '',
3955
placeholder: '',
4056
type: 'text',
4157
action: '',
58+
validLastAuthenticationStrategies: new Set<LastAuthenticationStrategy>(),
4259
},
4360
} as Record<SignInStartIdentifier | 'default', FirstFactorConfig>);
4461

packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,13 @@ function SignInStartInternal(): JSX.Element {
517517
}
518518

519519
// @ts-expect-error `action` is not typed
520-
const { action, ...identifierFieldProps } = identifierField.props;
520+
const { action, validLastAuthenticationStrategies, ...identifierFieldProps } = identifierField.props;
521+
522+
const lastAuthenticationStrategy = clerk.client?.lastAuthenticationStrategy;
523+
const isIdentifierLastAuthenticationStrategy = lastAuthenticationStrategy
524+
? validLastAuthenticationStrategies?.has(lastAuthenticationStrategy)
525+
: false;
526+
521527
return (
522528
<Flow.Part part='start'>
523529
{!alternativePhoneCodeProvider ? (
@@ -572,6 +578,7 @@ function SignInStartInternal(): JSX.Element {
572578
{...identifierFieldProps}
573579
autoFocus={shouldAutofocus}
574580
autoComplete={isWebAuthnAutofillSupported ? 'webauthn' : undefined}
581+
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
575582
/>
576583
</Form.ControlRow>
577584
<InstantPasswordRow field={passwordBasedInstance ? instantPasswordField : undefined} />

packages/clerk-js/src/ui/customizables/elementDescriptors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
7676
'socialButtonsProviderIcon',
7777
'socialButtonsProviderInitialIcon',
7878

79+
'lastAuthenticationStrategyBadge',
80+
7981
'enterpriseButtonsProviderIcon',
8082

8183
'providerIcon',
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { descriptors, localizationKeys, Span } from '../customizables';
2+
import { common, type PropsOfComponent } from '../styledSystem';
3+
4+
export type LastAuthenticationStrategyBadgeProps = PropsOfComponent<typeof Span> & { overlay?: boolean };
5+
export const LastAuthenticationStrategyBadge = ({
6+
sx,
7+
overlay,
8+
...props
9+
}: LastAuthenticationStrategyBadgeProps): JSX.Element => {
10+
return (
11+
<Span
12+
{...props}
13+
elementDescriptor={descriptors.lastAuthenticationStrategyBadge}
14+
localizationKey={localizationKeys('lastAuthenticationStrategy')}
15+
sx={[
16+
t => ({
17+
...common.textVariants(t).caption,
18+
background: `linear-gradient(${t.colors.$borderAlpha25}, transparent), ${t.colors.$colorBackground}`,
19+
border: `${t.space.$px} solid ${t.colors.$borderAlpha150}`,
20+
borderRadius: t.radii.$lg,
21+
color: t.colors.$colorMutedForeground,
22+
height: t.space.$4x5,
23+
paddingInline: t.space.$1x5,
24+
whiteSpace: 'nowrap',
25+
boxShadow: `0 0 0 1px ${t.colors.$colorBackground}`,
26+
...(overlay && {
27+
position: 'absolute',
28+
right: -1,
29+
top: -1,
30+
transform: `translate(${t.space.$2x5}, calc(-50% - ${t.space.$0x5}))`,
31+
pointerEvents: 'none',
32+
}),
33+
}),
34+
sx,
35+
]}
36+
/>
37+
);
38+
};

packages/clerk-js/src/ui/elements/Form.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { LocalizationKey } from '../customizables';
77
import { Button, Col, descriptors, Flex, Form as FormPrim, localizationKeys } from '../customizables';
88
import { useLoadingStatus } from '../hooks';
99
import type { PropsOfComponent } from '../styledSystem';
10+
import { LastAuthenticationStrategyBadge } from './Badge';
1011
import type { OTPInputProps } from './CodeControl';
1112
import { useCardState } from './contexts';
1213
import { Field } from './FieldControl';
@@ -123,10 +124,12 @@ type CommonInputProps = CommonFieldRootProps & {
123124
actionLabel?: string | LocalizationKey;
124125
onActionClicked?: React.MouseEventHandler;
125126
icon?: React.ComponentType;
127+
isLastAuthenticationStrategy?: boolean;
126128
};
127129

128130
const CommonInputWrapper = (props: PropsWithChildren<CommonInputProps>) => {
129-
const { isOptional, icon, actionLabel, children, onActionClicked, ...fieldProps } = props;
131+
const { isOptional, isLastAuthenticationStrategy, icon, actionLabel, children, onActionClicked, ...fieldProps } =
132+
props;
130133
return (
131134
<Field.Root {...fieldProps}>
132135
<Col
@@ -148,6 +151,9 @@ const CommonInputWrapper = (props: PropsWithChildren<CommonInputProps>) => {
148151
onClick={onActionClicked}
149152
/>
150153
)}
154+
{isLastAuthenticationStrategy && !actionLabel && !isOptional && (
155+
<LastAuthenticationStrategyBadgeWithFormState />
156+
)}
151157
<Field.Action />
152158
</Field.LabelRow>
153159

@@ -160,6 +166,20 @@ const CommonInputWrapper = (props: PropsWithChildren<CommonInputProps>) => {
160166
);
161167
};
162168

169+
const LastAuthenticationStrategyBadgeWithFormState = (
170+
props: PropsOfComponent<typeof LastAuthenticationStrategyBadge>,
171+
) => {
172+
const { isLoading, isDisabled } = useFormState();
173+
return (
174+
<LastAuthenticationStrategyBadge
175+
{...props}
176+
sx={t => ({
177+
opacity: isDisabled || isLoading ? t.opacity.$disabled : 1,
178+
})}
179+
/>
180+
);
181+
};
182+
163183
const PlainInput = (props: CommonInputProps) => {
164184
return (
165185
<CommonInputWrapper {...props}>

0 commit comments

Comments
 (0)