diff --git a/.changeset/heavy-hornets-yawn.md b/.changeset/heavy-hornets-yawn.md new file mode 100644 index 00000000000..3b5aee3f1df --- /dev/null +++ b/.changeset/heavy-hornets-yawn.md @@ -0,0 +1,9 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +'@clerk/clerk-expo': patch +--- + +Improve the UX on Reverification by not requiring the user's current password. + +The user has already verified themselves using Reverification, so there is no point to maintain a two level verification in case they would like to change their password. Also, Reverification is a stronger verification factor, as it includes strategies such as email code. diff --git a/packages/clerk-js/src/core/resources/AuthConfig.ts b/packages/clerk-js/src/core/resources/AuthConfig.ts index dae725e7db4..7de2c33b628 100644 --- a/packages/clerk-js/src/core/resources/AuthConfig.ts +++ b/packages/clerk-js/src/core/resources/AuthConfig.ts @@ -6,6 +6,7 @@ import { BaseResource } from './internal'; export class AuthConfig extends BaseResource implements AuthConfigResource { singleSessionMode!: boolean; claimedAt: Date | null = null; + reverification!: boolean; public constructor(data: AuthConfigJSON) { super(); @@ -15,6 +16,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { protected fromJSON(data: AuthConfigJSON | null): this { this.singleSessionMode = data ? data.single_session_mode : true; this.claimedAt = data?.claimed_at ? unixEpochToDate(data.claimed_at) : null; + this.reverification = data ? data.reverification : true; return this; } @@ -24,6 +26,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { id: this.id || '', single_session_mode: this.singleSessionMode, claimed_at: this.claimedAt ? this.claimedAt.getTime() : null, + reverification: this.reverification, }; } } diff --git a/packages/clerk-js/src/core/resources/__tests__/Environment.test.ts b/packages/clerk-js/src/core/resources/__tests__/Environment.test.ts index acc168988c1..813fb21be21 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Environment.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Environment.test.ts @@ -7,7 +7,13 @@ describe('Environment', () => { const environmentJSON = { object: 'environment', id: '', - auth_config: { object: 'auth_config', id: '', single_session_mode: true, claimed_at: null }, + auth_config: { + object: 'auth_config', + id: '', + single_session_mode: true, + claimed_at: null, + reverification: true, + }, display_config: { object: 'display_config', id: 'display_config_DUMMY_ID', @@ -240,7 +246,13 @@ describe('Environment', () => { const environmentJSON = { object: 'environment', id: '', - auth_config: { object: 'auth_config', id: '', single_session_mode: true, claimed_at: null }, + auth_config: { + object: 'auth_config', + id: '', + single_session_mode: true, + claimed_at: null, + reverification: true, + }, display_config: { object: 'display_config', id: 'display_config_DUMMY_ID', diff --git a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Environment.test.ts.snap b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Environment.test.ts.snap index 874997733be..5cce8ad56f0 100644 --- a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Environment.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Environment.test.ts.snap @@ -1,47 +1,49 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Environment has the same initial properties 1`] = ` -Environment { - "authConfig": AuthConfig { - "claimedAt": null, - "pathRoot": "", - "singleSessionMode": true, +exports[`Environment __internal_toSnapshot() 1`] = ` +{ + "auth_config": { + "claimed_at": null, + "id": "", + "object": "auth_config", + "reverification": true, + "single_session_mode": true, }, - "displayConfig": DisplayConfig { - "afterCreateOrganizationUrl": "", - "afterJoinWaitlistUrl": "", - "afterLeaveOrganizationUrl": "", - "afterSignInUrl": "", - "afterSignOutAllUrl": "", - "afterSignOutOneUrl": "", - "afterSignUpUrl": "", - "afterSwitchSessionUrl": "", - "applicationName": "", + "display_config": { + "after_create_organization_url": "", + "after_join_waitlist_url": "", + "after_leave_organization_url": "", + "after_sign_in_url": "", + "after_sign_out_all_url": "", + "after_sign_out_one_url": "", + "after_sign_up_url": "", + "after_switch_session_url": "", + "application_name": "", "branded": true, - "captchaHeartbeat": false, - "captchaHeartbeatIntervalMs": undefined, - "captchaOauthBypass": [], - "captchaProvider": null, - "captchaPublicKey": null, - "captchaPublicKeyInvisible": null, - "captchaWidgetType": null, - "clerkJSVersion": "5", - "createOrganizationUrl": "", - "faviconImageUrl": "", - "googleOneTapClientId": null, - "homeUrl": "", + "captcha_heartbeat": false, + "captcha_heartbeat_interval_ms": undefined, + "captcha_oauth_bypass": [], + "captcha_provider": null, + "captcha_public_key": null, + "captcha_public_key_invisible": null, + "captcha_widget_type": null, + "clerk_js_version": "5", + "create_organization_url": "", + "favicon_image_url": "", + "google_one_tap_client_id": null, + "home_url": "", "id": "display_config_DUMMY_ID", - "instanceEnvironmentType": "development", - "logoImageUrl": "", - "organizationProfileUrl": "", - "pathRoot": "", - "preferredSignInStrategy": "password", - "privacyPolicyUrl": null, - "showDevModeWarning": true, - "signInUrl": "", - "signUpUrl": "", - "supportEmail": "", - "termsUrl": null, + "instance_environment_type": "development", + "logo_image_url": "", + "object": "display_config", + "organization_profile_url": "", + "preferred_sign_in_strategy": "password", + "privacy_policy_url": null, + "show_devmode_warning": true, + "sign_in_url": "", + "sign_up_url": "", + "support_email": "", + "terms_url": null, "theme": { "accounts": { "background_color": "#ffffff", @@ -62,29 +64,25 @@ Environment { "padding": "1em", }, }, - "userProfileUrl": "", - "waitlistUrl": "", + "user_profile_url": "", + "waitlist_url": "", }, - "isDevelopmentOrStaging": [Function], - "isProduction": [Function], - "isSingleSession": [Function], - "maintenanceMode": false, - "onWindowLocationHost": [Function], - "organizationSettings": OrganizationSettings { + "id": "", + "maintenance_mode": false, + "object": "environment", + "organization_settings": { "actions": { - "adminDelete": true, + "admin_delete": true, }, "domains": { - "defaultRole": null, + "default_role": null, "enabled": false, - "enrollmentModes": [], + "enrollment_modes": [], }, "enabled": false, - "maxAllowedMemberships": 5, - "pathRoot": "", + "max_allowed_memberships": 5, }, - "pathRoot": "/environment", - "userSettings": UserSettings { + "user_settings": { "actions": { "create_organization": true, "create_organizations_limit": null, @@ -217,19 +215,11 @@ Environment { "verify_at_sign_up": false, }, }, - "authenticatableSocialStrategies": [ - "oauth_google", - ], - "enabledFirstFactorIdentifiers": [ - "email_address", - ], - "enterpriseSSO": undefined, - "id": undefined, - "passkeySettings": { + "passkey_settings": { "allow_autofill": true, "show_sign_in_button": true, }, - "passwordSettings": { + "password_settings": { "allowed_special_characters": "!"#$%&'()*+,-./:;<=>?@[]^_\`{|}~", "disable_hibp": false, "enforce_hibp_on_sign_in": true, @@ -242,16 +232,15 @@ Environment { "require_uppercase": false, "show_zxcvbn": false, }, - "pathRoot": "", "saml": { "enabled": false, }, - "signIn": { + "sign_in": { "second_factor": { "required": false, }, }, - "signUp": { + "sign_up": { "captcha_enabled": false, "captcha_widget_type": "smart", "custom_action_required": false, @@ -272,61 +261,53 @@ Environment { "strategy": "oauth_google", }, }, - "socialProviderStrategies": [ - "oauth_google", - ], - "usernameSettings": { - "max_length": NaN, - "min_length": NaN, - }, - "web3FirstFactors": [], }, } `; -exports[`Environment __internal_toSnapshot() 1`] = ` -{ - "auth_config": { - "claimed_at": null, - "id": "", - "object": "auth_config", - "single_session_mode": true, +exports[`Environment has the same initial properties 1`] = ` +Environment { + "authConfig": AuthConfig { + "claimedAt": null, + "pathRoot": "", + "reverification": true, + "singleSessionMode": true, }, - "display_config": { - "after_create_organization_url": "", - "after_join_waitlist_url": "", - "after_leave_organization_url": "", - "after_sign_in_url": "", - "after_sign_out_all_url": "", - "after_sign_out_one_url": "", - "after_sign_up_url": "", - "after_switch_session_url": "", - "application_name": "", + "displayConfig": DisplayConfig { + "afterCreateOrganizationUrl": "", + "afterJoinWaitlistUrl": "", + "afterLeaveOrganizationUrl": "", + "afterSignInUrl": "", + "afterSignOutAllUrl": "", + "afterSignOutOneUrl": "", + "afterSignUpUrl": "", + "afterSwitchSessionUrl": "", + "applicationName": "", "branded": true, - "captcha_heartbeat": false, - "captcha_heartbeat_interval_ms": undefined, - "captcha_oauth_bypass": [], - "captcha_provider": null, - "captcha_public_key": null, - "captcha_public_key_invisible": null, - "captcha_widget_type": null, - "clerk_js_version": "5", - "create_organization_url": "", - "favicon_image_url": "", - "google_one_tap_client_id": null, - "home_url": "", + "captchaHeartbeat": false, + "captchaHeartbeatIntervalMs": undefined, + "captchaOauthBypass": [], + "captchaProvider": null, + "captchaPublicKey": null, + "captchaPublicKeyInvisible": null, + "captchaWidgetType": null, + "clerkJSVersion": "5", + "createOrganizationUrl": "", + "faviconImageUrl": "", + "googleOneTapClientId": null, + "homeUrl": "", "id": "display_config_DUMMY_ID", - "instance_environment_type": "development", - "logo_image_url": "", - "object": "display_config", - "organization_profile_url": "", - "preferred_sign_in_strategy": "password", - "privacy_policy_url": null, - "show_devmode_warning": true, - "sign_in_url": "", - "sign_up_url": "", - "support_email": "", - "terms_url": null, + "instanceEnvironmentType": "development", + "logoImageUrl": "", + "organizationProfileUrl": "", + "pathRoot": "", + "preferredSignInStrategy": "password", + "privacyPolicyUrl": null, + "showDevModeWarning": true, + "signInUrl": "", + "signUpUrl": "", + "supportEmail": "", + "termsUrl": null, "theme": { "accounts": { "background_color": "#ffffff", @@ -347,25 +328,29 @@ exports[`Environment __internal_toSnapshot() 1`] = ` "padding": "1em", }, }, - "user_profile_url": "", - "waitlist_url": "", + "userProfileUrl": "", + "waitlistUrl": "", }, - "id": "", - "maintenance_mode": false, - "object": "environment", - "organization_settings": { + "isDevelopmentOrStaging": [Function], + "isProduction": [Function], + "isSingleSession": [Function], + "maintenanceMode": false, + "onWindowLocationHost": [Function], + "organizationSettings": OrganizationSettings { "actions": { - "admin_delete": true, + "adminDelete": true, }, "domains": { - "default_role": null, + "defaultRole": null, "enabled": false, - "enrollment_modes": [], + "enrollmentModes": [], }, "enabled": false, - "max_allowed_memberships": 5, + "maxAllowedMemberships": 5, + "pathRoot": "", }, - "user_settings": { + "pathRoot": "/environment", + "userSettings": UserSettings { "actions": { "create_organization": true, "create_organizations_limit": null, @@ -498,11 +483,19 @@ exports[`Environment __internal_toSnapshot() 1`] = ` "verify_at_sign_up": false, }, }, - "passkey_settings": { + "authenticatableSocialStrategies": [ + "oauth_google", + ], + "enabledFirstFactorIdentifiers": [ + "email_address", + ], + "enterpriseSSO": undefined, + "id": undefined, + "passkeySettings": { "allow_autofill": true, "show_sign_in_button": true, }, - "password_settings": { + "passwordSettings": { "allowed_special_characters": "!"#$%&'()*+,-./:;<=>?@[]^_\`{|}~", "disable_hibp": false, "enforce_hibp_on_sign_in": true, @@ -515,15 +508,16 @@ exports[`Environment __internal_toSnapshot() 1`] = ` "require_uppercase": false, "show_zxcvbn": false, }, + "pathRoot": "", "saml": { "enabled": false, }, - "sign_in": { + "signIn": { "second_factor": { "required": false, }, }, - "sign_up": { + "signUp": { "captcha_enabled": false, "captcha_widget_type": "smart", "custom_action_required": false, @@ -544,6 +538,14 @@ exports[`Environment __internal_toSnapshot() 1`] = ` "strategy": "oauth_google", }, }, + "socialProviderStrategies": [ + "oauth_google", + ], + "usernameSettings": { + "max_length": NaN, + "min_length": NaN, + }, + "web3FirstFactors": [], }, } `; diff --git a/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx index 6834d0b7733..fca08ebed9c 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/PasswordForm.tsx @@ -45,6 +45,11 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => return null; } + const { + userSettings: { passwordSettings }, + authConfig: { reverification }, + } = useEnvironment(); + const { session } = useSession(); const title = user.passwordEnabled ? localizationKeys('userProfile.passwordPage.title__update') @@ -52,6 +57,7 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => const card = useCardState(); const passwordEditDisabled = user.enterpriseAccounts.some(ea => ea.active); + const currentPasswordRequired = user.passwordEnabled && !reverification; // Ensure that messages will not use the updated state of User after a password has been set or changed const successPagePropsRef = useRef[0]>({ @@ -64,10 +70,6 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => isRequired: true, }); - const { - userSettings: { passwordSettings }, - } = useEnvironment(); - const passwordField = useFormControl('newPassword', '', { type: 'password', label: localizationKeys('formFieldLabel__newPassword'), @@ -96,7 +98,7 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => const { t, locale } = useLocalizations(); const canSubmit = - (user.passwordEnabled ? currentPasswordField.value && isPasswordMatch : isPasswordMatch) && + (currentPasswordRequired ? currentPasswordField.value && isPasswordMatch : isPasswordMatch) && passwordField.value && confirmField.value; @@ -118,7 +120,7 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => const opts = { newPassword: passwordField.value, signOutOfOtherSessions: sessionsField.checked, - currentPassword: user.passwordEnabled ? currentPasswordField.value : undefined, + currentPassword: currentPasswordRequired ? currentPasswordField.value : undefined, } satisfies Parameters[0]; await updatePasswordWithReverification(user, [opts]); @@ -145,7 +147,7 @@ export const PasswordForm = withCardStateProvider((props: PasswordFormProps) => value={session?.publicUserData.identifier || ''} style={{ display: 'none' }} /> - {user.passwordEnabled && ( + {currentPasswordRequired && ( { expect(getByRole('button', { name: /save$/i })).toBeDisabled(); }); - it('hides screen when when pressing cancel', async () => { + it('hides screen when pressing cancel', async () => { const { wrapper } = await createFixtures(initConfig); const { userEvent, getByRole, queryByRole } = render(, { wrapper }); @@ -315,6 +315,30 @@ describe('PasswordSection', () => { expect(queryByRole('heading', { name: /update password/i })).not.toBeInTheDocument(); }); + it('current password is not required when Reverification enabled', async () => { + const config = createFixtures.config(f => { + f.withReverification(); + f.withPassword(); + f.withUser({ password_enabled: true }); + }); + const { wrapper, fixtures } = await createFixtures(config); + + fixtures.clerk.user?.updatePassword.mockResolvedValue({}); + const { getByRole, userEvent, getByLabelText, queryByRole } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: /update password/i })); + await waitFor(() => getByRole('heading', { name: /update password/i })); + + await userEvent.type(getByLabelText(/new password/i), 'testtest'); + await userEvent.type(getByLabelText(/confirm password/i), 'testtest'); + await userEvent.click(getByRole('button', { name: /save$/i })); + expect(fixtures.clerk.user?.updatePassword).toHaveBeenCalledWith({ + newPassword: 'testtest', + signOutOfOtherSessions: true, + }); + await waitFor(() => getByRole('button', { name: /update password/i })); + expect(queryByRole('heading', { name: /update password/i })).not.toBeInTheDocument(); + }); + describe('with Enterprise SSO', () => { it('prevents changing a password if user has active enterprise connections', async () => { const emailAddress = 'george@jungle.com'; @@ -477,6 +501,24 @@ describe('PasswordSection', () => { expect(getByRole('button', { name: /save$/i })).toBeDisabled(); }); + + it('current password is not required when Reverification enabled', async () => { + const config = createFixtures.config(f => { + f.withReverification(); + f.withPassword(); + f.withUser({ password_enabled: true }); + }); + + const { wrapper } = await createFixtures(config); + const { getByRole, userEvent, getByLabelText } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: /update password/i })); + await waitFor(() => getByRole('heading', { name: /update password/i })); + + await userEvent.type(getByLabelText(/new password/i), 'testtest'); + await userEvent.type(getByLabelText(/confirm password/i), 'testtest'); + + expect(getByRole('button', { name: /save$/i })).toBeEnabled(); + }); }); }); diff --git a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts index 618589791b0..20d0244c7b2 100644 --- a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts @@ -281,7 +281,10 @@ const createAuthConfigFixtureHelpers = (environment: EnvironmentJSON) => { // TODO: ac.single_session_mode = false; }; - return { withMultiSessionMode }; + const withReverification = () => { + ac.reverification = true; + }; + return { withMultiSessionMode, withReverification }; }; const createDisplayConfigFixtureHelpers = (environment: EnvironmentJSON) => { diff --git a/packages/expo/src/cache/dummy-data/environment-resource.ts b/packages/expo/src/cache/dummy-data/environment-resource.ts index 0bc260dadf1..24d319c46ec 100644 --- a/packages/expo/src/cache/dummy-data/environment-resource.ts +++ b/packages/expo/src/cache/dummy-data/environment-resource.ts @@ -3,7 +3,13 @@ import type { EnvironmentJSONSnapshot } from '@clerk/types'; export const DUMMY_CLERK_ENVIRONMENT_RESOURCE = { object: 'environment', id: '', - auth_config: { object: 'auth_config', id: '', single_session_mode: true, claimed_at: null }, + auth_config: { + object: 'auth_config', + id: '', + single_session_mode: true, + claimed_at: null, + reverification: true, + }, display_config: { object: 'display_config', id: 'display_config_DUMMY_ID', diff --git a/packages/types/src/authConfig.ts b/packages/types/src/authConfig.ts index 35962a9a46c..e68fafd80aa 100644 --- a/packages/types/src/authConfig.ts +++ b/packages/types/src/authConfig.ts @@ -11,5 +11,9 @@ export interface AuthConfigResource extends ClerkResource { * Defaults to `null`. */ claimedAt: Date | null; + /** + * Whether Reverification is enabled at the instance level. + */ + reverification: boolean; __internal_toSnapshot: () => AuthConfigJSONSnapshot; } diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 32dada36382..46f32e48fe1 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -290,6 +290,7 @@ export interface SessionWithActivitiesJSON extends Omit { export interface AuthConfigJSON extends ClerkResourceJSON { single_session_mode: boolean; claimed_at: number | null; + reverification: boolean; } export interface VerificationJSON extends ClerkResourceJSON {