From edbcf9566626464afc769a51487c198424ded34e Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 7 May 2026 17:48:32 +0700 Subject: [PATCH 1/5] feat(passkey): display passkey text based on OS --- app/_locales/en/messages.json | 110 +++++++++++------- app/_locales/en_GB/messages.json | 110 +++++++++++------- shared/lib/passkey/index.ts | 1 + .../lib/passkey/passkey-auth-method.test.ts | 94 +++++++++++++++ shared/lib/passkey/passkey-auth-method.ts | 69 +++++++++++ shared/lib/passkey/passkey-error.test.ts | 56 ++++++--- shared/lib/passkey/passkey-error.ts | 15 ++- .../change-password/change-password.test.tsx | 17 ++- .../app/change-password/change-password.tsx | 37 ++++-- .../setup-passkey/setup-passkey.test.tsx | 24 +++- .../setup-passkey/setup-passkey.tsx | 34 ++++-- .../passkey-item.test.tsx | 6 +- .../passkey-item.tsx | 30 +++-- .../passkey-register-sub-page.tsx | 48 +++++--- .../passkey-turn-off-sub-page.test.tsx | 3 + .../passkey-turn-off-sub-page.tsx | 26 +++-- .../passkey/unlock-passkey-icon-button.tsx | 4 +- .../passkey/unlock-passkey-section.tsx | 8 +- 18 files changed, 519 insertions(+), 173 deletions(-) create mode 100644 shared/lib/passkey/passkey-auth-method.test.ts create mode 100644 shared/lib/passkey/passkey-auth-method.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 1b34b5692b17..bd1e25d67e96 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1548,10 +1548,6 @@ "changePasswordLoadingNote": { "message": "This shouldn't take long" }, - "changePasswordPasskeyVerifyingTitle": { - "message": "Confirm with Biometrics", - "description": "Title shown while the wallet waits for passkey or device biometrics during change password." - }, "changePasswordWarning": { "message": "Are you sure?" }, @@ -5287,62 +5283,77 @@ "participateInMetaMetricsDescription": { "message": "Participate in MetaMetrics to help us make MetaMask better" }, + "passkeyAuthMethodBiometrics": { + "message": "Biometrics", + "description": "Default OS-agnostic noun for the passkey auth method, used as $1 substitution in passkey messages on macOS / Linux / other OSes." + }, + "passkeyAuthMethodTouchId": { + "message": "Touch ID", + "description": "Apple Touch ID brand name, used as $1 substitution on macOS for descriptive passkey copy that names the underlying feature." + }, + "passkeyAuthMethodWindowsHello": { + "message": "Windows Hello", + "description": "Microsoft Windows Hello brand name, used as $1 substitution on Windows for all passkey copy." + }, "passkeyDescription": { - "message": "Use Touch ID to unlock MetaMask instead of entering your password. This creates a passkey on this device only." + "message": "Use $1 to unlock MetaMask instead of entering your password. This creates a passkey on this device only.", + "description": "Description of the passkey unlock feature on the setup / settings screens. $1 is the OS-specific auth-method noun (Touch ID on macOS, Windows Hello on Windows, Biometrics elsewhere)." }, "passkeyErrorAlreadyEnrolled": { - "message": "Biometrics are already set up for this wallet.", - "description": "Shown when starting passkey enrollment while a passkey is already enrolled." + "message": "$1 is already set up for this wallet.", + "description": "Shown when starting passkey enrollment while a passkey is already enrolled. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorAuthenticationVerificationFailed": { - "message": "We couldn't verify your Biometrics. Try again.", - "description": "Shown when passkey authentication response verification fails." + "message": "We couldn't verify your $1. Try again.", + "description": "Shown when passkey authentication response verification fails. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorMissingKeyMaterial": { - "message": "Your Biometrics response was incomplete. Try again.", - "description": "Shown when the passkey assertion is missing required key material." + "message": "Your $1 response was incomplete. Try again.", + "description": "Shown when the passkey assertion is missing required key material. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorNoAuthenticationCeremony": { - "message": "Biometrics sign-in session expired. Try again.", - "description": "Shown when passkey authentication runs without an active authentication ceremony." + "message": "$1 sign-in session expired. Try again.", + "description": "Shown when passkey authentication runs without an active authentication ceremony. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorNoRegistrationCeremony": { - "message": "Biometrics setup session expired. Try again from the beginning.", - "description": "Shown when passkey registration verification runs without an active registration ceremony." + "message": "$1 setup session expired. Try again from the beginning.", + "description": "Shown when passkey registration verification runs without an active registration ceremony. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorNotEnrolled": { - "message": "Biometrics aren't set up for this wallet. Turn on Biometrics in Settings.", - "description": "Shown when biometrics unlock fails because no passkey is enrolled." + "message": "$1 isn't set up for this wallet. Turn on $1 in Settings.", + "description": "Shown when biometrics unlock fails because no passkey is enrolled. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorRegistrationFailed": { - "message": "Biometrics setup failed. Try again", - "description": "Shown when onboarding biometrics registration fails with an unknown error (after specific biometrics error messages)." + "message": "$1 setup failed. Try again", + "description": "Shown when onboarding biometrics registration fails with an unknown error (after specific biometrics error messages). $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorRegistrationVerificationFailed": { - "message": "We couldn't verify your Biometrics setup. Try again.", - "description": "Shown when passkey registration response verification fails." + "message": "We couldn't verify your $1 setup. Try again.", + "description": "Shown when passkey registration response verification fails. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorVaultKeyDecryptionFailed": { - "message": "We couldn't unlock with Biometrics. Try again, or set up Biometrics again in Settings.", - "description": "Shown when the wrapped vault key cannot be decrypted with the passkey-derived key." + "message": "We couldn't unlock with $1. Try again, or set up $1 again in Settings.", + "description": "Shown when the wrapped vault key cannot be decrypted with the passkey-derived key. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorVaultKeyMismatch": { - "message": "Biometrics don't match your current wallet lock. Set up Biometrics again in Settings.", - "description": "Shown when renewing passkey protection and the decrypted key does not match the expected vault key." + "message": "$1 doesn't match your current wallet lock. Set up $1 again in Settings.", + "description": "Shown when renewing passkey protection and the decrypted key does not match the expected vault key. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorVaultKeyRenewalFailed": { - "message": "Your password was updated, but biometric unlock couldn't be turned on.", - "description": "Shown after a successful wallet password change when re-wrapping the vault key for passkey fails and passkey enrollment is cleared." + "message": "Your password was updated, but $1 unlock couldn't be turned on.", + "description": "Shown after a successful wallet password change when re-wrapping the vault key for passkey fails and passkey enrollment is cleared. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorVerificationFailed": { - "message": "We couldn't verify your Biometrics. Try again or use your password.", - "description": "Shown when biometrics verification fails while turning off biometrics unlock in Settings and the error has no known passkey code." + "message": "We couldn't verify your $1. Try again or use your password.", + "description": "Shown when biometrics verification fails while turning off biometrics unlock in Settings and the error has no known passkey code. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeySetupStepRegister": { - "message": "Register Biometrics" + "message": "Register $1", + "description": "Onboarding step label. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeySetupStepVerify": { - "message": "Validate Biometrics" + "message": "Validate $1", + "description": "Onboarding step label. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyTroubleshootModalDescription": { "message": "Try opening the extension in a full screen window and try again.", @@ -5373,13 +5384,24 @@ "description": "Title for the passkey troubleshoot modal when the user needs help verifying with a passkey outside of unlock (e.g. security settings or change password)." }, "passkeyTurnedOff": { - "message": "Biometrics turned off" + "message": "$1 turned off", + "description": "Toast shown after passkey unlock is turned off. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyTurnedOn": { - "message": "Biometrics turned on" + "message": "$1 turned on", + "description": "Toast shown after passkey unlock is turned on. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyUnlockFailed": { - "message": "Biometrics unlock failed. Try your password." + "message": "$1 unlock failed. Try your password.", + "description": "Generic fallback message when passkey unlock fails. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." + }, + "passkeyVerifyingDescription": { + "message": "Use $1 to verify instead of entering your password.", + "description": "Supporting line under the passkey verification heading while WebAuthn runs (e.g. change password). $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." + }, + "passkeyVerifyingTitle": { + "message": "Confirm with $1", + "description": "Heading while the wallet waits for passkey verification (e.g. change password). $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "password": { "message": "Password" @@ -7334,8 +7356,8 @@ "message": "Password couldn’t be changed. Please try again." }, "securityChangePasswordToastPasskeyRenewalFailed": { - "message": "New password saved, but biometric unlock couldn't be turned on.", - "description": "Toast when passkey vault key renewal fails after the new password was already applied." + "message": "New password saved, but $1 unlock couldn't be turned on.", + "description": "Toast when passkey vault key renewal fails after the new password was already applied. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "securityChangePasswordToastSuccess": { "message": "New password saved" @@ -7486,14 +7508,15 @@ "description": "Action label for Smart Accounts. Used on multichain details page." }, "setUpPasskey": { - "message": "Set up Biometrics" + "message": "Set up $1", + "description": "Action label for turning on passkey unlock. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "settingAddSnapAccount": { "message": "Add account Snap" }, "settingUpPasskey": { - "message": "Setting Up Biometrics", - "description": "Heading on the onboarding passkey setup screen while enrollment is in progress." + "message": "Setting up $1", + "description": "Heading on the onboarding passkey setup screen while enrollment is in progress. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "settings": { "message": "Settings" @@ -9609,10 +9632,12 @@ "message": "There was an error in disabling the notifications. Please try again later." }, "turnOffPasskey": { - "message": "Turn off Biometrics" + "message": "Turn off $1", + "description": "Action label for turning off passkey unlock. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "turnOffPasskeyFailed": { - "message": "We couldn't turn off Biometrics. Try again." + "message": "We couldn't turn off $1. Try again.", + "description": "Error toast when turning off passkey unlock fails. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "turnOn": { "message": "Turn on" @@ -9717,7 +9742,8 @@ "description": "Label used for Private Keys row on multichain account details page." }, "unlockWithPasskey": { - "message": "Unlock with Biometrics" + "message": "Unlock with $1", + "description": "Action label / aria-label for the passkey unlock button. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "unpin": { "message": "Unpin" diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 1b34b5692b17..bd1e25d67e96 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -1548,10 +1548,6 @@ "changePasswordLoadingNote": { "message": "This shouldn't take long" }, - "changePasswordPasskeyVerifyingTitle": { - "message": "Confirm with Biometrics", - "description": "Title shown while the wallet waits for passkey or device biometrics during change password." - }, "changePasswordWarning": { "message": "Are you sure?" }, @@ -5287,62 +5283,77 @@ "participateInMetaMetricsDescription": { "message": "Participate in MetaMetrics to help us make MetaMask better" }, + "passkeyAuthMethodBiometrics": { + "message": "Biometrics", + "description": "Default OS-agnostic noun for the passkey auth method, used as $1 substitution in passkey messages on macOS / Linux / other OSes." + }, + "passkeyAuthMethodTouchId": { + "message": "Touch ID", + "description": "Apple Touch ID brand name, used as $1 substitution on macOS for descriptive passkey copy that names the underlying feature." + }, + "passkeyAuthMethodWindowsHello": { + "message": "Windows Hello", + "description": "Microsoft Windows Hello brand name, used as $1 substitution on Windows for all passkey copy." + }, "passkeyDescription": { - "message": "Use Touch ID to unlock MetaMask instead of entering your password. This creates a passkey on this device only." + "message": "Use $1 to unlock MetaMask instead of entering your password. This creates a passkey on this device only.", + "description": "Description of the passkey unlock feature on the setup / settings screens. $1 is the OS-specific auth-method noun (Touch ID on macOS, Windows Hello on Windows, Biometrics elsewhere)." }, "passkeyErrorAlreadyEnrolled": { - "message": "Biometrics are already set up for this wallet.", - "description": "Shown when starting passkey enrollment while a passkey is already enrolled." + "message": "$1 is already set up for this wallet.", + "description": "Shown when starting passkey enrollment while a passkey is already enrolled. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorAuthenticationVerificationFailed": { - "message": "We couldn't verify your Biometrics. Try again.", - "description": "Shown when passkey authentication response verification fails." + "message": "We couldn't verify your $1. Try again.", + "description": "Shown when passkey authentication response verification fails. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorMissingKeyMaterial": { - "message": "Your Biometrics response was incomplete. Try again.", - "description": "Shown when the passkey assertion is missing required key material." + "message": "Your $1 response was incomplete. Try again.", + "description": "Shown when the passkey assertion is missing required key material. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorNoAuthenticationCeremony": { - "message": "Biometrics sign-in session expired. Try again.", - "description": "Shown when passkey authentication runs without an active authentication ceremony." + "message": "$1 sign-in session expired. Try again.", + "description": "Shown when passkey authentication runs without an active authentication ceremony. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorNoRegistrationCeremony": { - "message": "Biometrics setup session expired. Try again from the beginning.", - "description": "Shown when passkey registration verification runs without an active registration ceremony." + "message": "$1 setup session expired. Try again from the beginning.", + "description": "Shown when passkey registration verification runs without an active registration ceremony. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorNotEnrolled": { - "message": "Biometrics aren't set up for this wallet. Turn on Biometrics in Settings.", - "description": "Shown when biometrics unlock fails because no passkey is enrolled." + "message": "$1 isn't set up for this wallet. Turn on $1 in Settings.", + "description": "Shown when biometrics unlock fails because no passkey is enrolled. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorRegistrationFailed": { - "message": "Biometrics setup failed. Try again", - "description": "Shown when onboarding biometrics registration fails with an unknown error (after specific biometrics error messages)." + "message": "$1 setup failed. Try again", + "description": "Shown when onboarding biometrics registration fails with an unknown error (after specific biometrics error messages). $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorRegistrationVerificationFailed": { - "message": "We couldn't verify your Biometrics setup. Try again.", - "description": "Shown when passkey registration response verification fails." + "message": "We couldn't verify your $1 setup. Try again.", + "description": "Shown when passkey registration response verification fails. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorVaultKeyDecryptionFailed": { - "message": "We couldn't unlock with Biometrics. Try again, or set up Biometrics again in Settings.", - "description": "Shown when the wrapped vault key cannot be decrypted with the passkey-derived key." + "message": "We couldn't unlock with $1. Try again, or set up $1 again in Settings.", + "description": "Shown when the wrapped vault key cannot be decrypted with the passkey-derived key. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorVaultKeyMismatch": { - "message": "Biometrics don't match your current wallet lock. Set up Biometrics again in Settings.", - "description": "Shown when renewing passkey protection and the decrypted key does not match the expected vault key." + "message": "$1 doesn't match your current wallet lock. Set up $1 again in Settings.", + "description": "Shown when renewing passkey protection and the decrypted key does not match the expected vault key. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorVaultKeyRenewalFailed": { - "message": "Your password was updated, but biometric unlock couldn't be turned on.", - "description": "Shown after a successful wallet password change when re-wrapping the vault key for passkey fails and passkey enrollment is cleared." + "message": "Your password was updated, but $1 unlock couldn't be turned on.", + "description": "Shown after a successful wallet password change when re-wrapping the vault key for passkey fails and passkey enrollment is cleared. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyErrorVerificationFailed": { - "message": "We couldn't verify your Biometrics. Try again or use your password.", - "description": "Shown when biometrics verification fails while turning off biometrics unlock in Settings and the error has no known passkey code." + "message": "We couldn't verify your $1. Try again or use your password.", + "description": "Shown when biometrics verification fails while turning off biometrics unlock in Settings and the error has no known passkey code. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeySetupStepRegister": { - "message": "Register Biometrics" + "message": "Register $1", + "description": "Onboarding step label. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeySetupStepVerify": { - "message": "Validate Biometrics" + "message": "Validate $1", + "description": "Onboarding step label. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyTroubleshootModalDescription": { "message": "Try opening the extension in a full screen window and try again.", @@ -5373,13 +5384,24 @@ "description": "Title for the passkey troubleshoot modal when the user needs help verifying with a passkey outside of unlock (e.g. security settings or change password)." }, "passkeyTurnedOff": { - "message": "Biometrics turned off" + "message": "$1 turned off", + "description": "Toast shown after passkey unlock is turned off. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyTurnedOn": { - "message": "Biometrics turned on" + "message": "$1 turned on", + "description": "Toast shown after passkey unlock is turned on. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "passkeyUnlockFailed": { - "message": "Biometrics unlock failed. Try your password." + "message": "$1 unlock failed. Try your password.", + "description": "Generic fallback message when passkey unlock fails. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." + }, + "passkeyVerifyingDescription": { + "message": "Use $1 to verify instead of entering your password.", + "description": "Supporting line under the passkey verification heading while WebAuthn runs (e.g. change password). $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." + }, + "passkeyVerifyingTitle": { + "message": "Confirm with $1", + "description": "Heading while the wallet waits for passkey verification (e.g. change password). $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "password": { "message": "Password" @@ -7334,8 +7356,8 @@ "message": "Password couldn’t be changed. Please try again." }, "securityChangePasswordToastPasskeyRenewalFailed": { - "message": "New password saved, but biometric unlock couldn't be turned on.", - "description": "Toast when passkey vault key renewal fails after the new password was already applied." + "message": "New password saved, but $1 unlock couldn't be turned on.", + "description": "Toast when passkey vault key renewal fails after the new password was already applied. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "securityChangePasswordToastSuccess": { "message": "New password saved" @@ -7486,14 +7508,15 @@ "description": "Action label for Smart Accounts. Used on multichain details page." }, "setUpPasskey": { - "message": "Set up Biometrics" + "message": "Set up $1", + "description": "Action label for turning on passkey unlock. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "settingAddSnapAccount": { "message": "Add account Snap" }, "settingUpPasskey": { - "message": "Setting Up Biometrics", - "description": "Heading on the onboarding passkey setup screen while enrollment is in progress." + "message": "Setting up $1", + "description": "Heading on the onboarding passkey setup screen while enrollment is in progress. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "settings": { "message": "Settings" @@ -9609,10 +9632,12 @@ "message": "There was an error in disabling the notifications. Please try again later." }, "turnOffPasskey": { - "message": "Turn off Biometrics" + "message": "Turn off $1", + "description": "Action label for turning off passkey unlock. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "turnOffPasskeyFailed": { - "message": "We couldn't turn off Biometrics. Try again." + "message": "We couldn't turn off $1. Try again.", + "description": "Error toast when turning off passkey unlock fails. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "turnOn": { "message": "Turn on" @@ -9717,7 +9742,8 @@ "description": "Label used for Private Keys row on multichain account details page." }, "unlockWithPasskey": { - "message": "Unlock with Biometrics" + "message": "Unlock with $1", + "description": "Action label / aria-label for the passkey unlock button. $1 is the OS-specific auth-method noun (Biometrics / Touch ID / Windows Hello)." }, "unpin": { "message": "Unpin" diff --git a/shared/lib/passkey/index.ts b/shared/lib/passkey/index.ts index 32fb1a9032bc..4a6a0e00c1b5 100644 --- a/shared/lib/passkey/index.ts +++ b/shared/lib/passkey/index.ts @@ -14,3 +14,4 @@ export { translatePasskeyError, } from './passkey-error'; export { isPasskeyAaguidIncompatibleWithSidepanel } from './passkey-sidepanel-aaguid'; +export { getPasskeyAuthMethodKey } from './passkey-auth-method'; diff --git a/shared/lib/passkey/passkey-auth-method.test.ts b/shared/lib/passkey/passkey-auth-method.test.ts new file mode 100644 index 000000000000..cf1e437f9a93 --- /dev/null +++ b/shared/lib/passkey/passkey-auth-method.test.ts @@ -0,0 +1,94 @@ +import Bowser from 'bowser'; +import { OS } from '../../constants/app'; +import { getPasskeyAuthMethodKey } from './passkey-auth-method'; + +describe('getPasskeyAuthMethodKey', () => { + const getOSNameMock = jest.fn(); + let getParserSpy: jest.SpyInstance; + + beforeEach(() => { + getParserSpy = jest.spyOn(Bowser, 'getParser').mockReturnValue({ + getOSName: getOSNameMock, + } as unknown as Bowser.Parser.Parser); + }); + + afterEach(() => { + getParserSpy.mockRestore(); + }); + + describe('Windows', () => { + beforeEach(() => { + getOSNameMock.mockReturnValue('Windows'); + }); + + it('returns Windows Hello key', () => { + expect(getPasskeyAuthMethodKey()).toBe('passkeyAuthMethodWindowsHello'); + }); + + it('returns Windows Hello key even when specific is true', () => { + expect(getPasskeyAuthMethodKey({ specific: true })).toBe( + 'passkeyAuthMethodWindowsHello', + ); + }); + }); + + describe('macOS', () => { + beforeEach(() => { + getOSNameMock.mockReturnValue('macOS'); + }); + + it('returns Biometrics key by default', () => { + expect(getPasskeyAuthMethodKey()).toBe('passkeyAuthMethodBiometrics'); + }); + + it('returns Biometrics key when specific is false', () => { + expect(getPasskeyAuthMethodKey({ specific: false })).toBe( + 'passkeyAuthMethodBiometrics', + ); + }); + + it('returns Touch ID key when specific is true', () => { + expect(getPasskeyAuthMethodKey({ specific: true })).toBe( + 'passkeyAuthMethodTouchId', + ); + }); + }); + + for (const [label, bowserName] of [ + ['Linux', OS.LINUX], + ['iOS', OS.IOS], + ['Android', OS.ANDROID], + ] as const) { + describe(`non-Windows, non-macOS (${label})`, () => { + beforeEach(() => { + getOSNameMock.mockReturnValue(bowserName); + }); + + it('returns Biometrics key by default', () => { + expect(getPasskeyAuthMethodKey()).toBe('passkeyAuthMethodBiometrics'); + }); + + it('returns Biometrics key even when specific is true', () => { + expect(getPasskeyAuthMethodKey({ specific: true })).toBe( + 'passkeyAuthMethodBiometrics', + ); + }); + }); + } + + describe('unknown / other Bowser OS names', () => { + beforeEach(() => { + getOSNameMock.mockReturnValue('Chrome OS'); + }); + + it('returns Biometrics key by default', () => { + expect(getPasskeyAuthMethodKey()).toBe('passkeyAuthMethodBiometrics'); + }); + + it('returns Biometrics key even when specific is true', () => { + expect(getPasskeyAuthMethodKey({ specific: true })).toBe( + 'passkeyAuthMethodBiometrics', + ); + }); + }); +}); diff --git a/shared/lib/passkey/passkey-auth-method.ts b/shared/lib/passkey/passkey-auth-method.ts new file mode 100644 index 000000000000..db02b13f0112 --- /dev/null +++ b/shared/lib/passkey/passkey-auth-method.ts @@ -0,0 +1,69 @@ +import Bowser from 'bowser'; +import { OS, type Os } from '../../constants/app'; + +/** + * Normalized OS for passkey marketing copy (Bowser + user agent). + * Windows and macOS are distinguished; all other platforms map to {@link OS.OTHER}. + */ +function detectOsForPasskey(): Os { + const osName = Bowser.getParser(window.navigator.userAgent).getOSName(); + if (osName === 'Windows') { + return OS.WINDOWS; + } + if (osName === 'macOS') { + return OS.MACOS; + } + return OS.OTHER; +} + +/** + * i18n message keys for the localized passkey auth-method noun. + * Maps to copy in `app/_locales/*\/messages.json`. + */ +type PasskeyAuthMethodKey = + | 'passkeyAuthMethodBiometrics' + | 'passkeyAuthMethodTouchId' + | 'passkeyAuthMethodWindowsHello'; + +/** + * Optional configuration for {@link getPasskeyAuthMethodKey}. + */ +type GetPasskeyAuthMethodKeyOptions = { + /** + * When true, prefer the platform-specific feature name on macOS (Touch ID). + * Has no effect on Windows (always Windows Hello) or other OSes (always + * Biometrics). Use for descriptive copy that names the underlying feature + * (e.g. the "Use Touch ID to unlock..." description). + */ + specific?: boolean; +}; + +/** + * Returns the i18n key for the passkey auth-method noun shown in passkey copy. + * + * Uses {@link detectOsForPasskey} internally (Bowser on `window.navigator`). + * + * Resolution: + * - Windows: always `passkeyAuthMethodWindowsHello` ("Windows Hello"). + * - macOS with `{ specific: true }`: `passkeyAuthMethodTouchId` ("Touch ID"). + * - Everything else (macOS default, Linux, iOS, Android, Other, unknown): + * `passkeyAuthMethodBiometrics` ("Biometrics"). + * + * The noun is intentionally OS-driven marketing copy, not a runtime capability + * check; we do not detect Touch ID hardware or Windows Hello enrollment here. + * + * @param options - Optional flags such as `specific` for Touch ID on macOS. + * @returns The i18n key for the passkey auth-method noun. + */ +export function getPasskeyAuthMethodKey( + options?: GetPasskeyAuthMethodKeyOptions, +): PasskeyAuthMethodKey { + const os = detectOsForPasskey(); + if (os === OS.WINDOWS) { + return 'passkeyAuthMethodWindowsHello'; + } + if (os === OS.MACOS && options?.specific === true) { + return 'passkeyAuthMethodTouchId'; + } + return 'passkeyAuthMethodBiometrics'; +} diff --git a/shared/lib/passkey/passkey-error.test.ts b/shared/lib/passkey/passkey-error.test.ts index 81438b6426c9..0d50673b41c4 100644 --- a/shared/lib/passkey/passkey-error.test.ts +++ b/shared/lib/passkey/passkey-error.test.ts @@ -8,7 +8,11 @@ import { } from './passkey-error'; describe('translatePasskeyError', () => { - const t = (key: string) => `t:${key}`; + const t = (key: string, substitutions?: string[]) => + substitutions === undefined + ? `t:${key}` + : `t:${key}(${substitutions.join(',')})`; + const label = 'Biometrics'; it('maps MetaRPC-style data.cause.code to a translated string', () => { const err = new JsonRpcError(-32603, 'internal error', { @@ -17,8 +21,8 @@ describe('translatePasskeyError', () => { code: PasskeyControllerErrorCode.AuthenticationVerificationFailed, }, }); - expect(translatePasskeyError(err, t)).toBe( - 't:passkeyErrorAuthenticationVerificationFailed', + expect(translatePasskeyError(err, t, label)).toBe( + 't:passkeyErrorAuthenticationVerificationFailed(Biometrics)', ); }); @@ -26,8 +30,8 @@ describe('translatePasskeyError', () => { const err = { code: PasskeyControllerErrorCode.NoAuthenticationCeremony, }; - expect(translatePasskeyError(err, t)).toBe( - 't:passkeyErrorNoAuthenticationCeremony', + expect(translatePasskeyError(err, t, label)).toBe( + 't:passkeyErrorNoAuthenticationCeremony(Biometrics)', ); }); @@ -36,8 +40,9 @@ describe('translatePasskeyError', () => { translatePasskeyError( { code: PasskeyControllerErrorCode.VaultKeyDecryptionFailed }, t, + label, ), - ).toBe('t:passkeyErrorVaultKeyDecryptionFailed'); + ).toBe('t:passkeyErrorVaultKeyDecryptionFailed(Biometrics)'); }); it('maps a plain object with only `code` (registration verification failed)', () => { @@ -45,8 +50,9 @@ describe('translatePasskeyError', () => { translatePasskeyError( { code: PasskeyControllerErrorCode.RegistrationVerificationFailed }, t, + label, ), - ).toBe('t:passkeyErrorRegistrationVerificationFailed'); + ).toBe('t:passkeyErrorRegistrationVerificationFailed(Biometrics)'); }); it('maps already enrolled code', () => { @@ -56,12 +62,13 @@ describe('translatePasskeyError', () => { code: PasskeyControllerErrorCode.AlreadyEnrolled, }, t, + label, ), - ).toBe('t:passkeyErrorAlreadyEnrolled'); + ).toBe('t:passkeyErrorAlreadyEnrolled(Biometrics)'); }); it('returns null when no passkey-specific translation exists', () => { - expect(translatePasskeyError(new Error('x'), t)).toBeNull(); + expect(translatePasskeyError(new Error('x'), t, label)).toBeNull(); }); it('returns null when code is not a string', () => { @@ -76,12 +83,15 @@ describe('translatePasskeyError', () => { }, }, t, + label, ), ).toBeNull(); }); it('returns null when string code is unknown', () => { - expect(translatePasskeyError({ code: 'UnknownPasskeyCode' }, t)).toBeNull(); + expect( + translatePasskeyError({ code: 'UnknownPasskeyCode' }, t, label), + ).toBeNull(); }); it('prefers root string code over data.cause.code', () => { @@ -96,8 +106,9 @@ describe('translatePasskeyError', () => { }, }, t, + label, ), - ).toBe('t:passkeyErrorNoAuthenticationCeremony'); + ).toBe('t:passkeyErrorNoAuthenticationCeremony(Biometrics)'); }); it('supports the usual UI fallback with nullish coalescing', () => { @@ -105,11 +116,13 @@ describe('translatePasskeyError', () => { translatePasskeyError( { code: PasskeyControllerErrorCode.NoAuthenticationCeremony }, t, - ) ?? t('passkeyUnlockFailed'), - ).toBe('t:passkeyErrorNoAuthenticationCeremony'); + label, + ) ?? t('passkeyUnlockFailed', [label]), + ).toBe('t:passkeyErrorNoAuthenticationCeremony(Biometrics)'); expect( - translatePasskeyError(new Error('x'), t) ?? t('passkeyUnlockFailed'), - ).toBe('t:passkeyUnlockFailed'); + translatePasskeyError(new Error('x'), t, label) ?? + t('passkeyUnlockFailed', [label]), + ).toBe('t:passkeyUnlockFailed(Biometrics)'); }); it('maps extension vault key renewal failed code', () => { @@ -117,8 +130,19 @@ describe('translatePasskeyError', () => { translatePasskeyError( { code: ExtensionPasskeyErrorCode.VaultKeyRenewalFailed }, t, + label, + ), + ).toBe('t:passkeyErrorVaultKeyRenewalFailed(Biometrics)'); + }); + + it('forwards a Windows Hello label as substitution', () => { + expect( + translatePasskeyError( + { code: PasskeyControllerErrorCode.AlreadyEnrolled }, + t, + 'Windows Hello', ), - ).toBe('t:passkeyErrorVaultKeyRenewalFailed'); + ).toBe('t:passkeyErrorAlreadyEnrolled(Windows Hello)'); }); }); diff --git a/shared/lib/passkey/passkey-error.ts b/shared/lib/passkey/passkey-error.ts index 5f5af1aceae1..5ca106b0b822 100644 --- a/shared/lib/passkey/passkey-error.ts +++ b/shared/lib/passkey/passkey-error.ts @@ -54,10 +54,11 @@ export function getPasskeyControllerErrorCode(error: unknown): string | null { function translatePasskeyCode( code: string, - t: (key: string) => string, + t: (key: string, substitutions?: string[]) => string, + authMethodLabel: string, ): string | null { const i18nKey = PASSKEY_ERROR_CODE_TO_I18N_KEY[code]; - return i18nKey === undefined ? null : t(i18nKey); + return i18nKey === undefined ? null : t(i18nKey, [authMethodLabel]); } function getCauseCode(data: unknown): unknown { @@ -87,18 +88,22 @@ function getCauseCode(data: unknown): unknown { * Resolution order: * 1. String `code` on the rejection when it matches a known entry in the map above. * 2. `data.cause.code` for MetaRPC-wrapped rejections. - * 3. Otherwise `null` — callers typically use `?? t('passkeyUnlockFailed')`. + * 3. Otherwise `null` — callers typically use `?? t('passkeyUnlockFailed', [authMethodLabel])`. * * @param error - Thrown value from background or in-page passkey flows. * @param t - The extension i18n translation function from `useI18nContext()`. + * @param authMethodLabel - OS-specific passkey auth-method noun ("Biometrics" / + * "Touch ID" / "Windows Hello"); UI typically passes + * `t(getPasskeyAuthMethodKey())` (same noun as buttons / toasts, not the `{ specific: true }` variant). */ export function translatePasskeyError( error: unknown, - t: (key: string) => string, + t: (key: string, substitutions?: string[]) => string, + authMethodLabel: string, ): string | null { const code = getPasskeyControllerErrorCode(error); if (code === null) { return null; } - return translatePasskeyCode(code, t); + return translatePasskeyCode(code, t, authMethodLabel); } diff --git a/ui/components/app/change-password/change-password.test.tsx b/ui/components/app/change-password/change-password.test.tsx index f1222b650d0b..8c383c030e52 100644 --- a/ui/components/app/change-password/change-password.test.tsx +++ b/ui/components/app/change-password/change-password.test.tsx @@ -10,7 +10,7 @@ import { toast } from '../../ui/toast/toast'; import { getEnvironmentType } from '../../../../shared/lib/environment-type'; import { ENVIRONMENT_TYPE_SIDEPANEL } from '../../../../shared/constants/app'; import { renderWithProvider } from '../../../../test/lib/render-helpers-navigate'; -import { enLocale as messages } from '../../../../test/lib/i18n-helpers'; +import { tEn } from '../../../../test/lib/i18n-helpers'; import mockState from '../../../../test/data/mock-state.json'; import { SECURITY_ROUTE, @@ -19,6 +19,8 @@ import { import * as selectors from '../../../selectors'; import ChangePassword from './change-password'; +const PASSKEY_LABEL_BIOMETRICS = tEn('passkeyAuthMethodBiometrics'); + jest.mock('../../ui/toast/toast', () => { const actual = jest.requireActual( '../../ui/toast/toast', @@ -197,7 +199,7 @@ describe('ChangePassword', () => { await waitFor(() => { expect( - getByText(messages.unlockPageIncorrectPassword.message), + getByText(tEn('unlockPageIncorrectPassword')), ).toBeInTheDocument(); }); }); @@ -456,7 +458,12 @@ describe('ChangePassword', () => { getByTestId('change-password-passkey-verifying'), ).toBeInTheDocument(); expect( - getByText(messages.changePasswordPasskeyVerifyingTitle.message), + getByText(tEn('passkeyVerifyingTitle', [PASSKEY_LABEL_BIOMETRICS])), + ).toBeInTheDocument(); + expect( + getByText( + tEn('passkeyVerifyingDescription', [PASSKEY_LABEL_BIOMETRICS]), + ), ).toBeInTheDocument(); }); @@ -967,7 +974,9 @@ describe('ChangePassword', () => { props: { title: string }; }; expect(firstArg.props.title).toBe( - messages.securityChangePasswordToastPasskeyRenewalFailed.message, + tEn('securityChangePasswordToastPasskeyRenewalFailed', [ + PASSKEY_LABEL_BIOMETRICS, + ]), ); expect(mockForceUpdateMetamaskState).toHaveBeenCalled(); expect(mockUseNavigate).toHaveBeenCalledWith(SECURITY_ROUTE); diff --git a/ui/components/app/change-password/change-password.tsx b/ui/components/app/change-password/change-password.tsx index ae1e416fdc8f..91961d089fae 100644 --- a/ui/components/app/change-password/change-password.tsx +++ b/ui/components/app/change-password/change-password.tsx @@ -35,6 +35,7 @@ import Spinner from '../../ui/spinner'; import ToggleButton from '../../ui/toggle-button'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { + getPasskeyAuthMethodKey, startPasskeyAuthentication, cancelPasskeyCeremony, isPasskeyCeremonySilentError, @@ -95,6 +96,7 @@ const ChangePassword = ({ redirectRoute = SECURITY_ROUTE, }: ChangePasswordProps) => { const t = useI18nContext(); + const passkeyMethodLabel = t(getPasskeyAuthMethodKey()); const dispatch = useDispatch(); const navigate = useNavigate(); const { trackEvent } = useContext(MetaMetricsContext); @@ -232,11 +234,14 @@ const ChangePassword = ({ // upon successful password change, go back to the settings page navigate(redirectRoute); - const messageKey = - isPasskeyRenewalEnabled && !isPasskeyRenewalSuccessful - ? 'securityChangePasswordToastPasskeyRenewalFailed' - : 'securityChangePasswordToastSuccess'; - toast.success(, { + const isPasskeyRenewalToast = + isPasskeyRenewalEnabled && !isPasskeyRenewalSuccessful; + const toastTitle = isPasskeyRenewalToast + ? t('securityChangePasswordToastPasskeyRenewalFailed', [ + passkeyMethodLabel, + ]) + : t('securityChangePasswordToastSuccess'); + toast.success(, { duration: autoHideToastDelay, }); } catch (error) { @@ -308,8 +313,11 @@ const ChangePassword = ({ toast.error( string) ?? - t('passkeyErrorVerificationFailed') + translatePasskeyError( + error, + t as (key: string, substitutions?: string[]) => string, + passkeyMethodLabel, + ) ?? t('passkeyErrorVerificationFailed', [passkeyMethodLabel]) } />, { duration: autoHideToastDelay }, @@ -319,7 +327,7 @@ const ChangePassword = ({ } finally { setIsVerifyingPasskey(false); } - }, [t]); + }, [passkeyMethodLabel, t]); const openChangePasswordInFullScreen = useCallback(() => { cancelPasskeyCeremony(); @@ -496,7 +504,14 @@ const ChangePassword = ({ fontWeight={FontWeight.Medium} className="text-center" > - {t('changePasswordPasskeyVerifyingTitle')} + {t('passkeyVerifyingTitle', [passkeyMethodLabel])} + + + {t('passkeyVerifyingDescription', [passkeyMethodLabel])} {isSidePanel && isVerifyingPasskey && @@ -515,7 +530,7 @@ const ChangePassword = ({ type="button" data-testid="change-password-verify-passkey-use-password" color={TextColor.PrimaryDefault} - className="text-center" + className="text-center mt-4" onClick={handleUseVerifyPassword} > {t('usePassword')} @@ -572,7 +587,7 @@ const ChangePassword = ({ variant={TextVariant.BodyMd} fontWeight={FontWeight.Medium} > - {t('unlockWithPasskey')} + {t('unlockWithPasskey', [passkeyMethodLabel])} { beforeEach(() => { mockUseNavigate.mockClear(); @@ -169,21 +171,27 @@ describe('SetupPasskey', () => { const mockStore = buildMockStore(FirstTimeFlowType.create); const { getByText } = renderWithProvider(, mockStore); - expect(getByText(messages.unlockWithPasskey.message)).toBeInTheDocument(); + expect( + getByText(tEn('unlockWithPasskey', [PASSKEY_LABEL_BIOMETRICS])), + ).toBeInTheDocument(); }); it('renders the description text', () => { const mockStore = buildMockStore(FirstTimeFlowType.create); const { getByText } = renderWithProvider(, mockStore); - expect(getByText(messages.passkeyDescription.message)).toBeInTheDocument(); + expect( + getByText(tEn('passkeyDescription', [PASSKEY_LABEL_BIOMETRICS])), + ).toBeInTheDocument(); }); it('renders the set up biometrics button', () => { const mockStore = buildMockStore(FirstTimeFlowType.create); const { getByText } = renderWithProvider(, mockStore); - expect(getByText(messages.setUpPasskey.message)).toBeInTheDocument(); + expect( + getByText(tEn('setUpPasskey', [PASSKEY_LABEL_BIOMETRICS])), + ).toBeInTheDocument(); }); it('renders the maybe later button', () => { @@ -409,7 +417,9 @@ describe('SetupPasskey', () => { expect( screen.getByTestId('passkey-enrollment-error'), ).toHaveTextContent( - messages.passkeyErrorRegistrationVerificationFailed.message, + tEn('passkeyErrorRegistrationVerificationFailed', [ + PASSKEY_LABEL_BIOMETRICS, + ]), ); }); expect(mockUseNavigate).not.toHaveBeenCalled(); @@ -429,7 +439,9 @@ describe('SetupPasskey', () => { expect( screen.getByTestId('passkey-enrollment-error'), ).toHaveTextContent( - messages.passkeyErrorAuthenticationVerificationFailed.message, + tEn('passkeyErrorAuthenticationVerificationFailed', [ + PASSKEY_LABEL_BIOMETRICS, + ]), ); }); expect(mockUseNavigate).not.toHaveBeenCalled(); diff --git a/ui/pages/onboarding-flow/setup-passkey/setup-passkey.tsx b/ui/pages/onboarding-flow/setup-passkey/setup-passkey.tsx index f1a616df0b78..c16e6f4b219b 100644 --- a/ui/pages/onboarding-flow/setup-passkey/setup-passkey.tsx +++ b/ui/pages/onboarding-flow/setup-passkey/setup-passkey.tsx @@ -32,6 +32,7 @@ import { FirstTimeFlowType } from '../../../../shared/constants/onboarding'; import { PLATFORM_FIREFOX } from '../../../../shared/constants/app'; import { getBrowserName } from '../../../../shared/lib/browser-runtime.utils'; import { + getPasskeyAuthMethodKey, startPasskeyRegistration, startPasskeyAuthentication, translatePasskeyError, @@ -64,7 +65,14 @@ const DEFAULT_PASSKEY_ENROLLMENT_STEP_PHASE: PasskeyEnrollmentStepStatus = export default function SetupPasskey() { const navigate = useNavigate(); const dispatch = useDispatch(); - const t = useI18nContext() as (key: string) => string; + const t = useI18nContext() as ( + key: string, + substitutions?: string[], + ) => string; + const passkeyMethodLabel = t(getPasskeyAuthMethodKey()); + const passkeyMethodSpecificLabel = t( + getPasskeyAuthMethodKey({ specific: true }), + ); const firstTimeFlowType = useSelector(getFirstTimeFlowType); const isParticipateInMetaMetricsSet = useSelector( getIsParticipateInMetaMetricsSet, @@ -179,8 +187,8 @@ export default function SetupPasskey() { log.error('Onboarding passkey registration failed', error); if (isMountedRef.current) { setEnrollmentError( - translatePasskeyError(error, t) ?? - t('passkeyErrorRegistrationFailed'), + translatePasskeyError(error, t, passkeyMethodLabel) ?? + t('passkeyErrorRegistrationFailed', [passkeyMethodLabel]), ); } } finally { @@ -190,7 +198,7 @@ export default function SetupPasskey() { setVerifyStepPhase((prev) => (prev === 'loading' ? 'idle' : prev)); } } - }, [dispatch, t, goToNextStep]); + }, [dispatch, t, passkeyMethodLabel, goToNextStep]); if (isPasskeyRegistered && !isEnrollmentInProgress) { return null; @@ -219,14 +227,18 @@ export default function SetupPasskey() { fontWeight={FontWeight.Medium} color={TextColor.TextDefault} > - {t('settingUpPasskey')} + {t('settingUpPasskey', [passkeyMethodLabel])} @@ -237,10 +249,10 @@ export default function SetupPasskey() { fontWeight={FontWeight.Medium} color={TextColor.TextDefault} > - {t('unlockWithPasskey')} + {t('unlockWithPasskey', [passkeyMethodLabel])} - {t('passkeyDescription')} + {t('passkeyDescription', [passkeyMethodSpecificLabel])} {enrollmentError ? ( @@ -264,10 +276,10 @@ export default function SetupPasskey() { size={ButtonSize.Lg} className="w-full" data-testid="passkey-set-up-button" - aria-label={t('setUpPasskey')} + aria-label={t('setUpPasskey', [passkeyMethodLabel])} onClick={handleSetupPasskey} > - {t('setUpPasskey')} + {t('setUpPasskey', [passkeyMethodLabel])} { renderWithProvider(, mockStore); expect( - screen.getByText(messages.unlockWithPasskey.message), + screen.getByText( + tEn('unlockWithPasskey', [tEn('passkeyAuthMethodBiometrics')]), + ), ).toBeInTheDocument(); expect( screen.getByTestId('security-passkey-settings-toggle'), diff --git a/ui/pages/settings-v2/security-and-password-tab/passkey-item.tsx b/ui/pages/settings-v2/security-and-password-tab/passkey-item.tsx index 4296120234e8..ee48d35cf7f0 100644 --- a/ui/pages/settings-v2/security-and-password-tab/passkey-item.tsx +++ b/ui/pages/settings-v2/security-and-password-tab/passkey-item.tsx @@ -17,6 +17,7 @@ import { } from '../../../../shared/constants/metametrics'; import { SECOND } from '../../../../shared/constants/time'; import { + getPasskeyAuthMethodKey, startPasskeyAuthentication, cancelPasskeyCeremony, isPasskeyCeremonySilentError, @@ -47,7 +48,14 @@ import { SECURITY_ITEMS } from '../search-config'; const PASSKEY_SETTINGS_TOAST_DURATION_MS = 5 * SECOND; const PasskeyItem = () => { - const t = useI18nContext() as (key: string) => string; + const t = useI18nContext() as ( + key: string, + substitutions?: string[], + ) => string; + const passkeyMethodLabel = t(getPasskeyAuthMethodKey()); + const passkeyMethodSpecificLabel = t( + getPasskeyAuthMethodKey({ specific: true }), + ); const dispatch = useDispatch(); const navigate = useNavigate(); const { trackEvent } = useContext(MetaMetricsContext); @@ -111,9 +119,12 @@ const PasskeyItem = () => { await removePasskeyWithPasskeyVerification(authenticationResponse); await forceUpdateMetamaskState(dispatch); - toast.success(, { - duration: PASSKEY_SETTINGS_TOAST_DURATION_MS, - }); + toast.success( + , + { + duration: PASSKEY_SETTINGS_TOAST_DURATION_MS, + }, + ); trackEvent({ category: MetaMetricsEventCategory.Settings, @@ -139,8 +150,8 @@ const PasskeyItem = () => { toast.error( , { duration: PASSKEY_SETTINGS_TOAST_DURATION_MS }, @@ -155,6 +166,7 @@ const PasskeyItem = () => { isEnrolledPasskeyIncompatibleWithSidepanel, isPasskeyRegistered, navigate, + passkeyMethodLabel, t, trackEvent, ]); @@ -179,7 +191,7 @@ const PasskeyItem = () => { const description = useMemo(() => { const body = ( <> - {t('passkeyDescription')} + {t('passkeyDescription', [passkeyMethodSpecificLabel])} {isPasskeyOperationPending && getEnvironmentType() === ENVIRONMENT_TYPE_SIDEPANEL ? ( { ); return body; - }, [isPasskeyOperationPending, t]); + }, [isPasskeyOperationPending, passkeyMethodSpecificLabel, t]); if (!isPasskeyFeatureAvailable) { return null; @@ -204,7 +216,7 @@ const PasskeyItem = () => { return ( <> string; + const t = useI18nContext() as ( + key: string, + substitutions?: string[], + ) => string; + const passkeyMethodLabel = t(getPasskeyAuthMethodKey()); + const passkeyMethodSpecificLabel = t( + getPasskeyAuthMethodKey({ specific: true }), + ); const { trackEvent } = useContext(MetaMetricsContext); const isPasskeyRegistered = useSelector(getIsPasskeyRegistered); @@ -162,9 +170,12 @@ export default function PasskeyRegisterSubPage() { await new Promise((resolve) => { setTimeout(resolve, PASSKEY_ENROLLMENT_SUCCESS_DISPLAY_MS); }); - toast.success(, { - duration: PASSKEY_SETTINGS_TOAST_DURATION_MS, - }); + toast.success( + , + { + duration: PASSKEY_SETTINGS_TOAST_DURATION_MS, + }, + ); trackEvent({ category: MetaMetricsEventCategory.Settings, event: MetaMetricsEventName.SettingsUpdated, @@ -187,10 +198,12 @@ export default function PasskeyRegisterSubPage() { log.error('Settings passkey enrollment failed', error); setEnrollmentError( - translatePasskeyError(error, t) ?? + translatePasskeyError(error, t, passkeyMethodLabel) ?? (registrationSucceeded - ? t('passkeyErrorAuthenticationVerificationFailed') - : t('passkeyErrorRegistrationFailed')), + ? t('passkeyErrorAuthenticationVerificationFailed', [ + passkeyMethodLabel, + ]) + : t('passkeyErrorRegistrationFailed', [passkeyMethodLabel])), ); } finally { setIsEnrollmentInProgress(false); @@ -201,6 +214,7 @@ export default function PasskeyRegisterSubPage() { dispatch, goToSettings, isPasskeyRegistered, + passkeyMethodLabel, t, trackEvent, walletPassword, @@ -246,19 +260,19 @@ export default function PasskeyRegisterSubPage() { color={TextColor.TextAlternative} data-testid="register-passkey-intro-description" > - {t('passkeyDescription')} + {t('passkeyDescription', [passkeyMethodSpecificLabel])} @@ -323,15 +337,19 @@ export default function PasskeyRegisterSubPage() { color={TextColor.TextAlternative} data-testid="register-passkey-description" > - {t('passkeyDescription')} + {t('passkeyDescription', [passkeyMethodSpecificLabel])} {isEnrollmentInProgress ? ( ) : ( @@ -352,10 +370,10 @@ export default function PasskeyRegisterSubPage() { size={ButtonSize.Lg} className="w-full shrink-0" data-testid="register-passkey-set-up-button" - aria-label={t('setUpPasskey')} + aria-label={t('setUpPasskey', [passkeyMethodLabel])} onClick={beginPasskeyCeremonyFlow} > - {t('setUpPasskey')} + {t('setUpPasskey', [passkeyMethodLabel])} )} diff --git a/ui/pages/settings-v2/security-and-password-tab/passkey-turn-off-sub-page.test.tsx b/ui/pages/settings-v2/security-and-password-tab/passkey-turn-off-sub-page.test.tsx index 629103144242..27e02680a4b4 100644 --- a/ui/pages/settings-v2/security-and-password-tab/passkey-turn-off-sub-page.test.tsx +++ b/ui/pages/settings-v2/security-and-password-tab/passkey-turn-off-sub-page.test.tsx @@ -48,6 +48,9 @@ jest.mock('../../../store/actions', () => ({ })); jest.mock('../../../../shared/lib/passkey', () => ({ + ...jest.requireActual( + '../../../../shared/lib/passkey', + ), cancelPasskeyCeremony: jest.fn(), })); diff --git a/ui/pages/settings-v2/security-and-password-tab/passkey-turn-off-sub-page.tsx b/ui/pages/settings-v2/security-and-password-tab/passkey-turn-off-sub-page.tsx index dc2baba6665f..8a34cdd107c8 100644 --- a/ui/pages/settings-v2/security-and-password-tab/passkey-turn-off-sub-page.tsx +++ b/ui/pages/settings-v2/security-and-password-tab/passkey-turn-off-sub-page.tsx @@ -17,7 +17,10 @@ import { } from '../../../components/component-library'; import { SECURITY_AND_PASSWORD_ROUTE } from '../../../helpers/constants/routes'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { cancelPasskeyCeremony } from '../../../../shared/lib/passkey'; +import { + getPasskeyAuthMethodKey, + cancelPasskeyCeremony, +} from '../../../../shared/lib/passkey'; import { forceUpdateMetamaskState, removePasskeyWithPasswordVerification, @@ -38,6 +41,7 @@ export default function PasskeyTurnOffSubPage() { const navigate = useNavigate(); const dispatch = useDispatch(); const t = useI18nContext(); + const passkeyMethodLabel = t(getPasskeyAuthMethodKey()); const { trackEvent } = useContext(MetaMetricsContext); const isPasskeyRegistered = useSelector(getIsPasskeyRegistered); @@ -78,9 +82,12 @@ export default function PasskeyTurnOffSubPage() { try { await removePasskeyWithPasswordVerification(walletPassword); await forceUpdateMetamaskState(dispatch); - toast.success(, { - duration: PASSKEY_SETTINGS_TOAST_DURATION_MS, - }); + toast.success( + , + { + duration: PASSKEY_SETTINGS_TOAST_DURATION_MS, + }, + ); trackEvent({ category: MetaMetricsEventCategory.Settings, event: MetaMetricsEventName.SettingsUpdated, @@ -91,9 +98,14 @@ export default function PasskeyTurnOffSubPage() { }); goToSettings(); } catch { - toast.error(, { - duration: PASSKEY_SETTINGS_TOAST_DURATION_MS, - }); + toast.error( + , + { + duration: PASSKEY_SETTINGS_TOAST_DURATION_MS, + }, + ); goToSettings(); } } finally { diff --git a/ui/pages/unlock-page/passkey/unlock-passkey-icon-button.tsx b/ui/pages/unlock-page/passkey/unlock-passkey-icon-button.tsx index b38f457824a5..1909d8b693d4 100644 --- a/ui/pages/unlock-page/passkey/unlock-passkey-icon-button.tsx +++ b/ui/pages/unlock-page/passkey/unlock-passkey-icon-button.tsx @@ -7,6 +7,7 @@ import { IconColor, IconSize, } from '@metamask/design-system-react'; +import { getPasskeyAuthMethodKey } from '../../../../shared/lib/passkey'; import { useI18nContext } from '../../../hooks/useI18nContext'; export type UnlockPasskeyIconButtonProps = { @@ -19,11 +20,12 @@ export const UnlockPasskeyIconButton = ({ onClick, }: UnlockPasskeyIconButtonProps) => { const t = useI18nContext() as (key: string, ...args: unknown[]) => string; + const passkeyMethodLabel = t(getPasskeyAuthMethodKey()); return ( { const t = useI18nContext() as (key: string, ...args: unknown[]) => string; + const passkeyMethodLabel = t(getPasskeyAuthMethodKey()); const { trackEvent } = useContext(MetaMetricsContext); const [passkeyError, setPasskeyError] = useState(null); @@ -117,7 +119,8 @@ export const UnlockPasskeySection = ({ setPasskeyError(null); } else { setPasskeyError( - translatePasskeyError(err, t) ?? t('passkeyUnlockFailed'), + translatePasskeyError(err, t, passkeyMethodLabel) ?? + t('passkeyUnlockFailed', [passkeyMethodLabel]), ); } } finally { @@ -130,6 +133,7 @@ export const UnlockPasskeySection = ({ passkeyInProgress, isPasskeyActive, onUnlockWithPasskey, + passkeyMethodLabel, t, trackEvent, ]); @@ -205,7 +209,7 @@ export const UnlockPasskeySection = ({ onClick={handlePasskeyUnlockAction} aria-busy={passkeyInProgress} > - {t('unlockWithPasskey')} + {t('unlockWithPasskey', [passkeyMethodLabel])} {showTroubleshoot ? ( Date: Thu, 7 May 2026 18:11:43 +0700 Subject: [PATCH 2/5] chore(passkey): remove unused route params --- .../app/change-password/change-password.test.tsx | 2 -- ui/components/app/change-password/change-password.tsx | 1 - .../security-and-password-tab/passkey-item.test.tsx | 1 - .../security-and-password-tab/passkey-item.tsx | 1 - .../passkey/unlock-passkey-section.test.tsx | 10 ++-------- .../unlock-page/passkey/unlock-passkey-section.tsx | 5 +---- ui/pages/unlock-page/unlock-page.component.tsx | 5 +---- 7 files changed, 4 insertions(+), 21 deletions(-) diff --git a/ui/components/app/change-password/change-password.test.tsx b/ui/components/app/change-password/change-password.test.tsx index 8c383c030e52..a42a467ac717 100644 --- a/ui/components/app/change-password/change-password.test.tsx +++ b/ui/components/app/change-password/change-password.test.tsx @@ -672,7 +672,6 @@ describe('ChangePassword', () => { await waitFor(() => { expect(openExtensionInBrowser).toHaveBeenCalledWith( SECURITY_PASSWORD_CHANGE_V2_ROUTE, - 'from=sidepanel', ); }); @@ -733,7 +732,6 @@ describe('ChangePassword', () => { expect(jest.mocked(cancelPasskeyCeremony)).toHaveBeenCalled(); expect(openExtensionInBrowser).toHaveBeenCalledWith( SECURITY_PASSWORD_CHANGE_V2_ROUTE, - 'from=sidepanel', ); await act(async () => { diff --git a/ui/components/app/change-password/change-password.tsx b/ui/components/app/change-password/change-password.tsx index 91961d089fae..3ddfc1141f5b 100644 --- a/ui/components/app/change-password/change-password.tsx +++ b/ui/components/app/change-password/change-password.tsx @@ -333,7 +333,6 @@ const ChangePassword = ({ cancelPasskeyCeremony(); globalThis.platform?.openExtensionInBrowser?.( SECURITY_PASSWORD_CHANGE_V2_ROUTE, - 'from=sidepanel', ); }, []); diff --git a/ui/pages/settings-v2/security-and-password-tab/passkey-item.test.tsx b/ui/pages/settings-v2/security-and-password-tab/passkey-item.test.tsx index 784c89d41b68..2499608ea5b2 100644 --- a/ui/pages/settings-v2/security-and-password-tab/passkey-item.test.tsx +++ b/ui/pages/settings-v2/security-and-password-tab/passkey-item.test.tsx @@ -79,7 +79,6 @@ describe('PasskeyItem', () => { expect(openExtensionInBrowser).toHaveBeenCalledWith( SECURITY_AND_PASSWORD_ROUTE, - 'from=sidepanel', ); delete (globalThis as { platform?: unknown }).platform; diff --git a/ui/pages/settings-v2/security-and-password-tab/passkey-item.tsx b/ui/pages/settings-v2/security-and-password-tab/passkey-item.tsx index ee48d35cf7f0..a1086d35fb83 100644 --- a/ui/pages/settings-v2/security-and-password-tab/passkey-item.tsx +++ b/ui/pages/settings-v2/security-and-password-tab/passkey-item.tsx @@ -106,7 +106,6 @@ const PasskeyItem = () => { cancelPasskeyCeremony(); globalThis.platform?.openExtensionInBrowser?.( SECURITY_AND_PASSWORD_ROUTE, - 'from=sidepanel', ); return; } diff --git a/ui/pages/unlock-page/passkey/unlock-passkey-section.test.tsx b/ui/pages/unlock-page/passkey/unlock-passkey-section.test.tsx index b1ba24bfac33..f68994dd8986 100644 --- a/ui/pages/unlock-page/passkey/unlock-passkey-section.test.tsx +++ b/ui/pages/unlock-page/passkey/unlock-passkey-section.test.tsx @@ -217,10 +217,7 @@ describe('UnlockPasskeySection', () => { getByTestId('passkey-troubleshoot-open-full-screen-button'), ); - expect(mockOpenExtensionInBrowser).toHaveBeenCalledWith( - UNLOCK_ROUTE, - 'from=sidepanel', - ); + expect(mockOpenExtensionInBrowser).toHaveBeenCalledWith(UNLOCK_ROUTE); await act(async () => { resolveCeremony({ @@ -289,10 +286,7 @@ describe('UnlockPasskeySection', () => { fireEvent.click(getByTestId('unlock-passkey-button')); - expect(openExtensionInBrowser).toHaveBeenCalledWith( - UNLOCK_ROUTE, - 'from=sidepanel', - ); + expect(openExtensionInBrowser).toHaveBeenCalledWith(UNLOCK_ROUTE); expect(onUnlockWithPasskey).not.toHaveBeenCalled(); }); }); diff --git a/ui/pages/unlock-page/passkey/unlock-passkey-section.tsx b/ui/pages/unlock-page/passkey/unlock-passkey-section.tsx index 222761fd8a8d..6c68b91e5783 100644 --- a/ui/pages/unlock-page/passkey/unlock-passkey-section.tsx +++ b/ui/pages/unlock-page/passkey/unlock-passkey-section.tsx @@ -152,10 +152,7 @@ export const UnlockPasskeySection = ({ const openUnlockInFullScreen = useCallback(() => { cancelPasskeyCeremony(); - globalThis.platform?.openExtensionInBrowser?.( - UNLOCK_ROUTE, - 'from=sidepanel', - ); + globalThis.platform?.openExtensionInBrowser?.(UNLOCK_ROUTE); }, []); const handlePasskeyUnlockAction = useCallback(() => { diff --git a/ui/pages/unlock-page/unlock-page.component.tsx b/ui/pages/unlock-page/unlock-page.component.tsx index f3ee25735caa..ba629ef67836 100644 --- a/ui/pages/unlock-page/unlock-page.component.tsx +++ b/ui/pages/unlock-page/unlock-page.component.tsx @@ -546,10 +546,7 @@ class UnlockPage extends Component { handleUnlockPasskeyFromPasswordForm = () => { if (this.props.mustDeferPasskeyToBrowserTab) { cancelPasskeyCeremony(); - globalThis.platform?.openExtensionInBrowser?.( - UNLOCK_ROUTE, - 'from=sidepanel', - ); + globalThis.platform?.openExtensionInBrowser?.(UNLOCK_ROUTE); return; } this.setPasswordUnlockMode(false); From 5a41d9ff3a4b1a4212e0af11be92dd3eb2ef5d66 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 7 May 2026 18:36:39 +0700 Subject: [PATCH 3/5] fix(passkey): address sonarqube issue --- shared/lib/passkey/passkey-auth-method.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/shared/lib/passkey/passkey-auth-method.ts b/shared/lib/passkey/passkey-auth-method.ts index db02b13f0112..af78fd6777e8 100644 --- a/shared/lib/passkey/passkey-auth-method.ts +++ b/shared/lib/passkey/passkey-auth-method.ts @@ -6,7 +6,9 @@ import { OS, type Os } from '../../constants/app'; * Windows and macOS are distinguished; all other platforms map to {@link OS.OTHER}. */ function detectOsForPasskey(): Os { - const osName = Bowser.getParser(window.navigator.userAgent).getOSName(); + const osName = Bowser.getParser( + globalThis.navigator.userAgent, + ).getOSName(); if (osName === 'Windows') { return OS.WINDOWS; } @@ -41,7 +43,7 @@ type GetPasskeyAuthMethodKeyOptions = { /** * Returns the i18n key for the passkey auth-method noun shown in passkey copy. * - * Uses {@link detectOsForPasskey} internally (Bowser on `window.navigator`). + * Uses {@link detectOsForPasskey} internally (Bowser on `globalThis.navigator`). * * Resolution: * - Windows: always `passkeyAuthMethodWindowsHello` ("Windows Hello"). From 77e0b4b67b5d69e338104db62070734701bb4af9 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 7 May 2026 20:22:19 +0700 Subject: [PATCH 4/5] feat(passkey): handle OS-based passkey labels in settings --- shared/lib/passkey/passkey-auth-method.ts | 4 +- ui/pages/settings-v2/settings-v2.tsx | 4 +- .../settings-v2-search-results.test.tsx | 30 ++++++- .../shared/settings-v2-search-results.tsx | 4 +- .../settings-v2/useSettingsV2I18n.test.ts | 78 +++++++++++++++++++ ui/pages/settings-v2/useSettingsV2I18n.ts | 40 ++++++++++ .../settings-v2/useSettingsV2Search.test.ts | 11 ++- ui/pages/settings-v2/useSettingsV2Search.ts | 4 +- 8 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 ui/pages/settings-v2/useSettingsV2I18n.test.ts create mode 100644 ui/pages/settings-v2/useSettingsV2I18n.ts diff --git a/shared/lib/passkey/passkey-auth-method.ts b/shared/lib/passkey/passkey-auth-method.ts index af78fd6777e8..7f2dfea495fb 100644 --- a/shared/lib/passkey/passkey-auth-method.ts +++ b/shared/lib/passkey/passkey-auth-method.ts @@ -6,9 +6,7 @@ import { OS, type Os } from '../../constants/app'; * Windows and macOS are distinguished; all other platforms map to {@link OS.OTHER}. */ function detectOsForPasskey(): Os { - const osName = Bowser.getParser( - globalThis.navigator.userAgent, - ).getOSName(); + const osName = Bowser.getParser(globalThis.navigator.userAgent).getOSName(); if (osName === 'Windows') { return OS.WINDOWS; } diff --git a/ui/pages/settings-v2/settings-v2.tsx b/ui/pages/settings-v2/settings-v2.tsx index 54fb0d56a8a8..21088ac0d460 100644 --- a/ui/pages/settings-v2/settings-v2.tsx +++ b/ui/pages/settings-v2/settings-v2.tsx @@ -30,7 +30,6 @@ import { } from '@metamask/design-system-react'; import classnames from 'clsx'; import { useSelector } from 'react-redux'; -import { useI18nContext } from '../../hooks/useI18nContext'; import { DEFAULT_ROUTE, SETTINGS_V2_ROUTE, @@ -73,6 +72,7 @@ import { SettingsV2SearchResults, } from './shared'; import { useSettingsV2Search, MIN_SEARCH_LENGTH } from './useSettingsV2Search'; +import { useSettingsV2I18n } from './useSettingsV2I18n'; const FIRST_TAB_PATH = SETTINGS_V2_TABS[0]?.path; const FirstTabComponent = SETTINGS_V2_TABS[0]?.component; @@ -91,7 +91,7 @@ const getRoutePathname = (path: string) => path.split('?')[0]; const SettingsV2Layout = ({ children }: { children: React.ReactNode }) => { const navigate = useNavigate(); const location = useLocation(); - const t = useI18nContext(); + const t = useSettingsV2I18n(); const normalizedPathname = normalizeSettingsPath(location.pathname); const meta = getSettingsV2RouteMeta(normalizedPathname); const environmentType = getEnvironmentType(); diff --git a/ui/pages/settings-v2/shared/settings-v2-search-results.test.tsx b/ui/pages/settings-v2/shared/settings-v2-search-results.test.tsx index d27191dc7161..efc30a36afb8 100644 --- a/ui/pages/settings-v2/shared/settings-v2-search-results.test.tsx +++ b/ui/pages/settings-v2/shared/settings-v2-search-results.test.tsx @@ -4,10 +4,14 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import mockState from '../../../../test/data/mock-state.json'; import { renderWithProvider } from '../../../../test/lib/render-helpers-navigate'; -import { enLocale as messages } from '../../../../test/lib/i18n-helpers'; +import { enLocale as messages, tEn } from '../../../../test/lib/i18n-helpers'; import type { SettingsV2SearchResult } from '../useSettingsV2Search'; import { SettingsV2SearchResults } from './settings-v2-search-results'; +jest.mock('../../../../shared/lib/passkey', () => ({ + getPasskeyAuthMethodKey: () => 'passkeyAuthMethodBiometrics', +})); + const createMockStore = () => configureMockStore([thunk])(mockState); const mockItems: SettingsV2SearchResult[] = [ @@ -46,6 +50,30 @@ describe('SettingsV2SearchResults', () => { ).toBeInTheDocument(); }); + it('resolves passkey title keys with auth-method substitution', () => { + const passkeyItem: SettingsV2SearchResult = { + settingId: 'passkey', + tabLabelKey: 'securityAndPassword', + titleKey: 'unlockWithPasskey', + tabRoute: '/settings-v2/security-and-password', + iconName: 'SecurityKey', + }; + + renderWithProvider( + , + createMockStore(), + ); + + expect( + screen.getByText( + `${messages.securityAndPassword.message} > ${tEn('unlockWithPasskey', [tEn('passkeyAuthMethodBiometrics')])}`, + ), + ).toBeInTheDocument(); + }); + it('calls onClickResult when a result is clicked', () => { const onClickResult = jest.fn(); renderWithProvider( diff --git a/ui/pages/settings-v2/shared/settings-v2-search-results.tsx b/ui/pages/settings-v2/shared/settings-v2-search-results.tsx index 7ce00ae00741..f424f4e48648 100644 --- a/ui/pages/settings-v2/shared/settings-v2-search-results.tsx +++ b/ui/pages/settings-v2/shared/settings-v2-search-results.tsx @@ -12,8 +12,8 @@ import { IconColor, IconName, } from '@metamask/design-system-react'; -import { useI18nContext } from '../../../hooks/useI18nContext'; import { REQUEST_SETTING_URL } from '../../../../shared/lib/ui-utils'; +import { useSettingsV2I18n } from '../useSettingsV2I18n'; import type { SettingsV2SearchResult } from '../useSettingsV2Search'; import { Divider } from './divider'; @@ -26,7 +26,7 @@ export const SettingsV2SearchResults = ({ results, onClickResult, }: SettingsV2SearchResultsProps) => { - const t = useI18nContext(); + const t = useSettingsV2I18n(); return ( ({ + getPasskeyAuthMethodKey: jest.fn(() => 'passkeyAuthMethodBiometrics'), +})); + +jest.mock('../../hooks/useI18nContext'); + +const mockUseI18nContext = jest.mocked(useI18nContext); + +const { getPasskeyAuthMethodKey } = jest.requireMock< + typeof import('../../../shared/lib/passkey') +>('../../../shared/lib/passkey'); + +describe('useSettingsV2I18n', () => { + beforeEach(() => { + mockUseI18nContext.mockReset(); + }); + + it('forwards to raw t when substitutions are passed', () => { + const rawT = jest.fn(() => 'forwarded'); + mockUseI18nContext.mockReturnValue(rawT); + + const { result } = renderHook(() => useSettingsV2I18n()); + expect(result.current('settingsSearchCantFindSetting', [{}])).toBe( + 'forwarded', + ); + + expect(rawT).toHaveBeenCalledWith('settingsSearchCantFindSetting', [{}]); + }); + + it('fills passkey placeholder for unlockWithPasskey when only key is passed', () => { + const rawT = jest.fn((key: string, substitutions?: string[]) => { + if (key === 'passkeyAuthMethodBiometrics') { + return 'Biometrics'; + } + if (key === 'unlockWithPasskey' && substitutions?.[0] === 'Biometrics') { + return 'Unlock with Biometrics'; + } + return key; + }); + mockUseI18nContext.mockReturnValue(rawT); + + const { result } = renderHook(() => useSettingsV2I18n()); + expect(result.current('unlockWithPasskey')).toBe('Unlock with Biometrics'); + expect(getPasskeyAuthMethodKey).toHaveBeenCalled(); + expect(rawT).toHaveBeenCalledWith('passkeyAuthMethodBiometrics'); + expect(rawT).toHaveBeenCalledWith('unlockWithPasskey', ['Biometrics']); + }); + + it('fills passkey placeholder for setUpPasskey and turnOffPasskey', () => { + const rawT = jest.fn((key: string, substitutions?: string[]) => { + if (key === 'passkeyAuthMethodBiometrics') { + return 'X'; + } + if (substitutions?.[0] === 'X') { + return `${key} done`; + } + return key; + }); + mockUseI18nContext.mockReturnValue(rawT); + + const { result } = renderHook(() => useSettingsV2I18n()); + expect(result.current('setUpPasskey')).toBe('setUpPasskey done'); + expect(result.current('turnOffPasskey')).toBe('turnOffPasskey done'); + }); + + it('delegates to raw t for keys without passkey substitution', () => { + const rawT = jest.fn((key: string) => `:${key}:`); + mockUseI18nContext.mockReturnValue(rawT); + + const { result } = renderHook(() => useSettingsV2I18n()); + expect(result.current('theme')).toBe(':theme:'); + expect(rawT).toHaveBeenCalledWith('theme'); + }); +}); diff --git a/ui/pages/settings-v2/useSettingsV2I18n.ts b/ui/pages/settings-v2/useSettingsV2I18n.ts new file mode 100644 index 000000000000..7bc4244fecb0 --- /dev/null +++ b/ui/pages/settings-v2/useSettingsV2I18n.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; +import { useI18nContext } from '../../hooks/useI18nContext'; +import { getPasskeyAuthMethodKey } from '../../../shared/lib/passkey'; + +const PASSKEY_SUBSTITUTED_LABEL_KEYS: ReadonlySet = new Set([ + 'setUpPasskey', + 'turnOffPasskey', + 'unlockWithPasskey', +]); + +type TranslateMessage = (key: string, substitutions?: string[]) => string; + +function translateBareLabelKey(t: TranslateMessage, labelKey: string): string { + if (PASSKEY_SUBSTITUTED_LABEL_KEYS.has(labelKey)) { + return t(labelKey, [t(getPasskeyAuthMethodKey())]); + } + return t(labelKey); +} + +/** + * Settings V2 i18n: same as {@link useI18nContext}, but bare `t(key)` calls + * resolve passkey-related label keys that need the OS-specific auth-method + * noun. Calls with any additional arguments forward unchanged (substitutions, + * React nodes, etc.). + * + * @returns Wrapped translate function + */ +export function useSettingsV2I18n() { + const rawT = useI18nContext(); + + return useCallback( + (key: string, ...args: unknown[]) => { + if (args.length > 0) { + return rawT(key, ...args); + } + return translateBareLabelKey(rawT as TranslateMessage, key); + }, + [rawT], + ); +} diff --git a/ui/pages/settings-v2/useSettingsV2Search.test.ts b/ui/pages/settings-v2/useSettingsV2Search.test.ts index db7d939554d2..0d73be5bcc61 100644 --- a/ui/pages/settings-v2/useSettingsV2Search.test.ts +++ b/ui/pages/settings-v2/useSettingsV2Search.test.ts @@ -1,8 +1,17 @@ import { renderHook } from '@testing-library/react-hooks'; import { useSettingsV2Search } from './useSettingsV2Search'; +jest.mock('../../../shared/lib/passkey', () => ({ + getPasskeyAuthMethodKey: () => 'passkeyAuthMethodBiometrics', +})); + jest.mock('../../hooks/useI18nContext', () => ({ - useI18nContext: () => (key: string) => key, + useI18nContext: () => (key: string, substitutions?: string[]) => { + if (substitutions?.length) { + return `${key}(${substitutions.join(',')})`; + } + return key; + }, })); jest.mock('./settings-registry', () => ({ diff --git a/ui/pages/settings-v2/useSettingsV2Search.ts b/ui/pages/settings-v2/useSettingsV2Search.ts index eff494838b21..af808e78d672 100644 --- a/ui/pages/settings-v2/useSettingsV2Search.ts +++ b/ui/pages/settings-v2/useSettingsV2Search.ts @@ -1,8 +1,8 @@ import { useMemo } from 'react'; import Fuse from 'fuse.js'; -import { useI18nContext } from '../../hooks/useI18nContext'; import { SETTINGS_V2_TABS, SETTINGS_V2_ROUTES } from './settings-registry'; import { SETTINGS_V2_SEARCH_CONFIG } from './search-config'; +import { useSettingsV2I18n } from './useSettingsV2I18n'; export const MIN_SEARCH_LENGTH = 3; @@ -67,7 +67,7 @@ function buildSearchableItems(): SettingsV2SearchResult[] { export function useSettingsV2Search( searchValue: string, ): SettingsV2SearchResult[] { - const t = useI18nContext(); + const t = useSettingsV2I18n(); const fuse = useMemo(() => { const items = buildSearchableItems(); From fa0bd372258c1e43921ce128afcf173daf0a5fe9 Mon Sep 17 00:00:00 2001 From: Tai Nguyen TT Date: Thu, 7 May 2026 14:38:28 +0700 Subject: [PATCH 5/5] temp: enable passkey in main --- builds.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/builds.yml b/builds.yml index 0a0ec52d8630..abf7598430f1 100644 --- a/builds.yml +++ b/builds.yml @@ -456,4 +456,4 @@ env: - FORCE_AUTH_MATCH_BUILD: false # Passkey / Biometrics unlock - - PASSKEY_ENABLED: 'false' + - PASSKEY_ENABLED: 'true'