diff --git a/libs/accounts/passkey/src/lib/passkey.repository.in.spec.ts b/libs/accounts/passkey/src/lib/passkey.repository.in.spec.ts index 7b8112c98e2..6bcaca0656c 100644 --- a/libs/accounts/passkey/src/lib/passkey.repository.in.spec.ts +++ b/libs/accounts/passkey/src/lib/passkey.repository.in.spec.ts @@ -62,7 +62,7 @@ describe('PasskeyRepository (Integration)', () => { }); describe('update operations', () => { - it('should update counter and lastUsed after authentication', async () => { + it('should update counter and lastUsedAt after authentication', async () => { const uid = await createTestAccount(); const passkey = PasskeyFactory({ uid }); await PasskeyRepository.insertPasskey(db, passkey); diff --git a/packages/fxa-auth-server/lib/routes/account.spec.ts b/packages/fxa-auth-server/lib/routes/account.spec.ts index 29da3c84a77..d6f0a7bd196 100644 --- a/packages/fxa-auth-server/lib/routes/account.spec.ts +++ b/packages/fxa-auth-server/lib/routes/account.spec.ts @@ -4819,7 +4819,7 @@ describe('/account', () => { const result: any = await runTest(route, request); expect(mockService.listPasskeysForUser).toHaveBeenCalledWith( - Buffer.from(uid) + Buffer.from(uid, 'hex') ); expect(result.passkeys).toHaveLength(1); expect(result.passkeys[0]).toEqual({ diff --git a/packages/fxa-auth-server/lib/routes/account.ts b/packages/fxa-auth-server/lib/routes/account.ts index c9a83187a70..bba1da5ba90 100644 --- a/packages/fxa-auth-server/lib/routes/account.ts +++ b/packages/fxa-auth-server/lib/routes/account.ts @@ -2457,7 +2457,9 @@ export class AccountHandler { this.db.devices(uid), listAuthorizedClients(uid), this.config.passkeys?.enabled - ? Container.get(PasskeyService).listPasskeysForUser(Buffer.from(uid)) + ? Container.get(PasskeyService).listPasskeysForUser( + Buffer.from(uid, 'hex') + ) : Promise.resolve([]), ]); diff --git a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts index 3d6951d8411..8d8d48625eb 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts @@ -41,7 +41,7 @@ describe('passkeys routes', () => { mockPasskeyService: any, mockFxaMailer: any; - const UID = 'uid-123'; + const UID = 'f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6'; const SESSION_TOKEN_ID = 'session-token-456'; const TEST_EMAIL = 'test@example.com'; const CREDENTIAL_ID_B64 = diff --git a/packages/fxa-content-server/server/config/local.json-dist b/packages/fxa-content-server/server/config/local.json-dist index ab0f8efdcaa..035e00dcb50 100644 --- a/packages/fxa-content-server/server/config/local.json-dist +++ b/packages/fxa-content-server/server/config/local.json-dist @@ -75,7 +75,10 @@ "featureFlags": { "recoveryCodeSetupOnSyncSignIn": true, "showLocaleToggle": true, - "paymentsNextSubscriptionManagement": true + "paymentsNextSubscriptionManagement": true, + "passkeysEnabled": true, + "passkeyRegistrationEnabled": true, + "passkeyAuthenticationEnabled": false }, "darkMode": { "enabled": true diff --git a/packages/fxa-settings/src/components/Settings/PagePasskeyAdd/index.test.tsx b/packages/fxa-settings/src/components/Settings/PagePasskeyAdd/index.test.tsx index 4db041b9155..39273b5bc3a 100644 --- a/packages/fxa-settings/src/components/Settings/PagePasskeyAdd/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/PagePasskeyAdd/index.test.tsx @@ -85,6 +85,7 @@ const mockCredential = { function renderPage() { const account = { getCachedJwtByScope: jest.fn(() => 'mock-jwt'), + refresh: jest.fn(), } as unknown as Account; const authClient = { diff --git a/packages/fxa-settings/src/components/Settings/PagePasskeyAdd/index.tsx b/packages/fxa-settings/src/components/Settings/PagePasskeyAdd/index.tsx index 393a9e9c9b7..65e1e13bc27 100644 --- a/packages/fxa-settings/src/components/Settings/PagePasskeyAdd/index.tsx +++ b/packages/fxa-settings/src/components/Settings/PagePasskeyAdd/index.tsx @@ -79,6 +79,7 @@ export const PagePasskeyAdd = () => { credential, challenge ); + await account.refresh('passkeys'); if (!isMounted.current) return; diff --git a/packages/fxa-settings/src/components/Settings/SubRow/index.stories.tsx b/packages/fxa-settings/src/components/Settings/SubRow/index.stories.tsx index 6cf5a25b5fc..073654b0508 100644 --- a/packages/fxa-settings/src/components/Settings/SubRow/index.stories.tsx +++ b/packages/fxa-settings/src/components/Settings/SubRow/index.stories.tsx @@ -14,7 +14,7 @@ import { withLocalization, withLocation } from 'fxa-react/lib/storybooks'; import { CodeIcon } from '../../Icons'; import { MOCK_NATIONAL_FORMAT_PHONE_NUMBER } from '../../../pages/mocks'; import { AppContext } from '../../../models'; -import { mockAppContext } from '../../../models/mocks'; +import { MOCK_ACCOUNT, mockAppContext } from '../../../models/mocks'; import { initLocalAccount, mockAuthClient } from './mock'; export default { @@ -25,9 +25,16 @@ export default { withLocation(), (Story) => { initLocalAccount(); + const mockAccount = { + ...MOCK_ACCOUNT, + deletePasskey: async () => {}, + }; return ( {/* @container/unitRow on the container div allows sub row to adjust based on the size of the parent container instead of the viewport. This fixes issues with the subrow and CTAs overflowing their parent container in mobileLandscape diff --git a/packages/fxa-settings/src/components/Settings/SubRow/index.test.tsx b/packages/fxa-settings/src/components/Settings/SubRow/index.test.tsx index 6dd245ebb00..ded6bd52068 100644 --- a/packages/fxa-settings/src/components/Settings/SubRow/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/SubRow/index.test.tsx @@ -18,6 +18,7 @@ import { import { Passkey } from 'fxa-auth-client/browser'; import { AppContext } from '../../../models'; import { mockAppContext } from '../../../models/mocks'; +import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import { mockAuthClient } from './mock'; import { LocationProvider } from '@reach/router'; @@ -28,6 +29,10 @@ const mockAlertBar = { info: jest.fn(), }; +let mockAccount = { + deletePasskey: jest.fn(), +}; + jest.mock('../../../lib/cache', () => ({ ...jest.requireActual('../../../lib/cache'), JwtTokenCache: { @@ -42,6 +47,7 @@ jest.mock('../../../models', () => ({ ...jest.requireActual('../../../models'), useAuthClient: () => mockAuthClient, useAlertBar: () => mockAlertBar, + useAccount: () => mockAccount, })); describe('SubRow', () => { @@ -280,22 +286,17 @@ describe('PasskeySubRow', () => { prfEnabled: true, }; - const mockDeletePasskey = jest.fn(); - beforeEach(() => { - mockDeletePasskey.mockClear(); + mockAccount.deletePasskey.mockClear(); mockAlertBar.success.mockClear(); mockAlertBar.error.mockClear(); }); - const renderPasskeySubRow = ( - passkey: Passkey = mockPasskey, - deletePasskey = mockDeletePasskey - ) => { + const renderPasskeySubRow = (passkey: Passkey = mockPasskey) => { return render( - + ); @@ -350,7 +351,7 @@ describe('PasskeySubRow', () => { }); it('calls deletePasskey when confirm button is clicked', async () => { - mockDeletePasskey.mockResolvedValue(undefined); + mockAccount.deletePasskey.mockResolvedValue(undefined); renderPasskeySubRow(); const deleteButtons = screen.getAllByTitle(/Delete passkey/); @@ -361,13 +362,11 @@ describe('PasskeySubRow', () => { const confirmButton = screen.getByTestId('confirm-delete-passkey-button'); await userEvent.click(confirmButton); - await waitFor(() => { - expect(mockDeletePasskey).toHaveBeenCalledWith('passkey-1'); - }); + expect(mockAccount.deletePasskey).toHaveBeenCalledWith('passkey-1'); }); it('shows success banner when deletion succeeds', async () => { - mockDeletePasskey.mockResolvedValue(undefined); + mockAccount.deletePasskey.mockResolvedValue(undefined); renderPasskeySubRow(); const deleteButtons = screen.getAllByTitle(/Delete passkey/); @@ -389,8 +388,8 @@ describe('PasskeySubRow', () => { }); }); - it('shows error banner when deletion fails', async () => { - mockDeletePasskey.mockRejectedValue(new Error('Some error')); + it('shows generic error banner when deletion fails with an unexpected error', async () => { + mockAccount.deletePasskey.mockRejectedValue(new Error('Some error')); renderPasskeySubRow(); const deleteButtons = screen.getAllByTitle(/Delete passkey/); @@ -413,4 +412,27 @@ describe('PasskeySubRow', () => { ).not.toBeInTheDocument(); }); }); + + it('shows "Passkey not found" error when passkey no longer exists', async () => { + mockAccount.deletePasskey.mockRejectedValue(AuthUiErrors.PASSKEY_NOT_FOUND); + renderPasskeySubRow(); + + const deleteButtons = screen.getAllByTitle(/Delete passkey/); + await userEvent.click(deleteButtons[0]); + + expect(await screen.findByText('Delete your passkey?')).toBeInTheDocument(); + + const confirmButton = screen.getByTestId('confirm-delete-passkey-button'); + await userEvent.click(confirmButton); + + await waitFor(() => { + expect(mockAlertBar.error).toHaveBeenCalledWith('Passkey not found'); + }); + + await waitFor(() => { + expect( + screen.queryByText('Delete your passkey?') + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/fxa-settings/src/components/Settings/SubRow/index.tsx b/packages/fxa-settings/src/components/Settings/SubRow/index.tsx index 6e5f8620db3..7efc6e81841 100644 --- a/packages/fxa-settings/src/components/Settings/SubRow/index.tsx +++ b/packages/fxa-settings/src/components/Settings/SubRow/index.tsx @@ -5,8 +5,10 @@ import React, { useCallback, useState } from 'react'; import classNames from 'classnames'; import { Passkey } from 'fxa-auth-client/browser'; -import { useAlertBar, useFtlMsgResolver } from '../../../models'; -import { MfaGuard } from '../MfaGuard'; +import { useAlertBar, useAccount, useFtlMsgResolver } from '../../../models'; +import { isAuthUiError } from '../../../lib/auth-errors/auth-errors'; +import { getLocalizedErrorMessage } from '../../../lib/error-utils'; +import { MfaGuard, useMfaErrorHandler } from '../MfaGuard'; import { MfaReason } from '../../../lib/types'; import { AlertFullIcon as AlertIcon, @@ -345,8 +347,6 @@ export const BackupPhoneSubRow = ({ export type PasskeySubRowProps = { passkey: Passkey; - // TODO: replace with actual auth client API call - deletePasskey?: (credentialId: string) => Promise; }; const formatDateText = (timestamp: number): string => { @@ -357,20 +357,25 @@ const formatDateText = (timestamp: number): string => { }).format(new Date(timestamp)); }; -export const PasskeySubRow = ({ +type PasskeyDeleteModalProps = { + passkey: Passkey; + onDismiss: () => void; +}; + +const PasskeyDeleteModal = ({ passkey, - deletePasskey = async (passkeyId: string) => {}, -}: PasskeySubRowProps) => { + onDismiss, +}: PasskeyDeleteModalProps) => { + const account = useAccount(); const ftlMsgResolver = useFtlMsgResolver(); const alertBar = useAlertBar(); - const [deleteModalRevealed, revealDeleteModal, hideDeleteModal] = - useBooleanState(); const [isDeleting, setIsDeleting] = useState(false); + const handleMfaError = useMfaErrorHandler(); const handleConfirmDelete = useCallback(async () => { setIsDeleting(true); try { - await deletePasskey(passkey.credentialId); + await account.deletePasskey(passkey.credentialId); // a hack to avoid alert bar being immediately removed setTimeout(() => { alertBar.success( @@ -378,27 +383,80 @@ export const PasskeySubRow = ({ ); }, 0); } catch (error) { - setTimeout(() => { - alertBar.error( - // TODO: replace with more specific error messages based on error type - ftlMsgResolver.getMsg( + if (handleMfaError(error)) { + return; + } + const localizedError = isAuthUiError(error) + ? getLocalizedErrorMessage(ftlMsgResolver, error) + : ftlMsgResolver.getMsg( 'passkey-delete-error', 'There was a problem deleting your passkey. Try again in a few minutes.' - ) - ); - }, 0); + ); + setTimeout(() => alertBar.error(localizedError), 0); } finally { setIsDeleting(false); - hideDeleteModal(); + onDismiss(); } }, [ + account, passkey.credentialId, - deletePasskey, alertBar, ftlMsgResolver, - hideDeleteModal, + onDismiss, + handleMfaError, ]); + return ( + + +

+ Delete your passkey? +

+
+ +

+ This passkey will be removed from your account. You’ll need to sign in + using a different way. +

+
+
+ + + + + + +
+
+ ); +}; + +export const PasskeySubRow = ({ passkey }: PasskeySubRowProps) => { + const ftlMsgResolver = useFtlMsgResolver(); + const [deleteModalRevealed, revealDeleteModal, hideDeleteModal] = + useBooleanState(); + const createdDateFluent = getLocalizedDate( passkey.createdAt, LocalizedDateOptions.NumericDate @@ -410,9 +468,8 @@ export const PasskeySubRow = ({ ? getLocalizedDate(passkey.lastUsedAt, LocalizedDateOptions.NumericDate) : undefined; - const lastUsedText = passkey.lastUsedAt - ? formatDateText(passkey.lastUsedAt) - : undefined; + const lastUsedText = + passkey.lastUsedAt != null ? formatDateText(passkey.lastUsedAt) : undefined; const localizedDescription = ( @@ -458,48 +515,7 @@ export const PasskeySubRow = ({ }} reason={MfaReason.removePasskey} > - - -

- Delete your passkey? -

-
- -

- This passkey will be removed from your account. You’ll need to - sign in using a different way. -

-
-
- - - - - - -
-
+ )} diff --git a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.stories.tsx b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.stories.tsx index e6c3fe7f825..b0454c626c3 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.stories.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.stories.tsx @@ -7,8 +7,9 @@ import { Meta } from '@storybook/react'; import { withLocalization } from 'fxa-react/lib/storybooks'; import { LocationProvider } from '@reach/router'; import UnitRowPasskey from '.'; -import { AppContext } from 'fxa-settings/src/models'; +import { Account, AppContext } from 'fxa-settings/src/models'; import { + MOCK_ACCOUNT, mockAppContext, mockSettingsContext, } from 'fxa-settings/src/models/mocks'; @@ -66,9 +67,6 @@ const storyWithMockPasskeys = ( passkeys: Passkey[], { webAuthnSupported = true }: { webAuthnSupported?: boolean } = {} ) => { - const authClient = { - listPasskeys: () => Promise.resolve(passkeys), - }; const story = () => { initLocalAccount(); // Stub the WebAuthn Level 3 feature check for storybook previews. @@ -82,10 +80,17 @@ const storyWithMockPasskeys = ( } else { delete w.PublicKeyCredential; } + const mockAccount = { + ...MOCK_ACCOUNT, + passkeys, + deletePasskey: async () => {}, + }; return ( diff --git a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.test.tsx b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.test.tsx index 30e1be127ae..977aa3baa03 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.test.tsx @@ -8,8 +8,8 @@ import { LocationProvider } from '@reach/router'; import UnitRowPasskey from './index'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import { Passkey } from 'fxa-auth-client/browser'; - -const mockListPasskeys = jest.fn(); +import { Account, AppContext } from '../../../models'; +import { MOCK_ACCOUNT, mockAppContext } from '../../../models/mocks'; jest.mock('../SubRow', () => ({ ...jest.requireActual('../SubRow'), @@ -23,19 +23,6 @@ jest.mock('../../../models', () => ({ useFtlMsgResolver: () => ({ getMsg: (_id: string, fallback: string) => fallback, }), - useAuthClient: () => ({ - listPasskeys: mockListPasskeys, - }), -})); - -jest.mock('../../../lib/cache', () => ({ - ...jest.requireActual('../../../lib/cache'), - JwtTokenCache: { - hasToken: jest.fn(() => true), - subscribe: jest.fn(() => () => {}), - getSnapshot: jest.fn(() => ({})), - }, - sessionToken: jest.fn(() => 'session-123'), })); jest.mock('../../../lib/passkeys/webauthn', () => ({ @@ -71,17 +58,31 @@ const mockPasskeys: Passkey[] = [ }, ]; +let mockAccount = { + ...MOCK_ACCOUNT, + passkeys: mockPasskeys, +}; + describe('UnitRowPasskey', () => { beforeEach(() => { jest.clearAllMocks(); - mockListPasskeys.mockResolvedValue(mockPasskeys); isWebAuthnLevel3Supported.mockReturnValue(true); + mockAccount = { + ...MOCK_ACCOUNT, + passkeys: mockPasskeys, + }; }); const renderUnitRowPasskey = () => { return renderWithLocalizationProvider( - + + + ); }; @@ -110,7 +111,7 @@ describe('UnitRowPasskey', () => { }); it('displays "Not set" when no passkeys exist', async () => { - mockListPasskeys.mockResolvedValue([]); + mockAccount.passkeys = []; renderUnitRowPasskey(); await waitFor(() => { expect(screen.getByText('Not set')).toBeInTheDocument(); @@ -165,11 +166,11 @@ describe('UnitRowPasskey', () => { backupState: false, prfEnabled: false, })); - mockListPasskeys.mockResolvedValue(atMaxPasskeys); + mockAccount.passkeys = atMaxPasskeys; renderUnitRowPasskey(); await waitFor(() => { expect( - screen.getByText(/You\u2019ve used all 10 passkeys/) + screen.getByText(/You’ve used all 10 passkeys/) ).toBeInTheDocument(); }); expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); diff --git a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.tsx b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.tsx index 75b31d8f597..d90f2f7cebb 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowPasskey/index.tsx @@ -2,49 +2,25 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useState } from 'react'; import UnitRow, { UnitRowProps } from '../UnitRow'; -import { useAuthClient, useConfig, useFtlMsgResolver } from '../../../models'; +import { useAccount, useFtlMsgResolver, useConfig } from '../../../models'; import { FtlMsg } from 'fxa-react/lib/utils'; import LinkExternal from 'fxa-react/components/LinkExternal'; import { PasskeySubRow } from '../SubRow'; -import { Passkey } from 'fxa-auth-client/browser'; import { isWebAuthnLevel3Supported } from '../../../lib/passkeys/webauthn'; -import { sessionToken } from '../../../lib/cache'; import { Banner } from '../../Banner'; export const UnitRowPasskey = () => { + const account = useAccount(); const ftlMsgResolver = useFtlMsgResolver(); - const authClient = useAuthClient(); const config = useConfig(); const maxPasskeys = config.passkeys.maxPerUser; - const [passkeys, setPasskeys] = useState([]); - const [loading, setLoading] = useState(true); - const [showWebAuthnError, setShowWebAuthnError] = useState(false); - - const fetchPasskeys = useCallback(async () => { - const token = sessionToken(); - if (!token) { - setLoading(false); - return; - } - try { - const result = await authClient.listPasskeys(token); - setPasskeys(result); - } catch { - // Silently fail — passkeys list will appear empty - } finally { - setLoading(false); - } - }, [authClient]); - - useEffect(() => { - fetchPasskeys(); - }, [fetchPasskeys]); - + const passkeys = account.passkeys; const hasPasskeys = passkeys.length > 0; const isAtLimit = passkeys.length >= maxPasskeys; const webAuthnSupported = isWebAuthnLevel3Supported(); + const [showWebAuthnError, setShowWebAuthnError] = useState(false); const conditionalUnitRowProps: Partial = hasPasskeys ? { @@ -103,40 +79,34 @@ export const UnitRowPasskey = () => { ); - if (loading) { - return <>; - } - return ( - <> - setShowWebAuthnError(true) : undefined - } - disabled={webAuthnSupported && isAtLimit} - disabledReason={ftlMsgResolver.getMsg( - 'passkey-row-max-limit-disabled-reason', - "You've reached the maximum number of passkeys." - )} - {...conditionalUnitRowProps} - subRows={getSubRows()} - > - -

- Make sign in easier and more secure by using your phone or other - supported device to get into your account. -

-
- {learnMoreLink} -
- + setShowWebAuthnError(true) : undefined + } + disabled={webAuthnSupported && isAtLimit} + disabledReason={ftlMsgResolver.getMsg( + 'passkey-row-max-limit-disabled-reason', + "You've reached the maximum number of passkeys." + )} + {...conditionalUnitRowProps} + subRows={getSubRows()} + > + +

+ Make sign in easier and more secure by using your phone or other + supported device to get into your account. +

+
+ {learnMoreLink} +
); }; diff --git a/packages/fxa-settings/src/lib/account-storage.ts b/packages/fxa-settings/src/lib/account-storage.ts index 1d4fbe2e272..04acefa8257 100644 --- a/packages/fxa-settings/src/lib/account-storage.ts +++ b/packages/fxa-settings/src/lib/account-storage.ts @@ -11,6 +11,7 @@ import { LinkedAccount, SecurityEvent, } from '../models/Account'; +import type { Passkey } from 'fxa-auth-client/browser'; import { lazy } from './test-utils'; import Storage from './storage'; @@ -52,6 +53,7 @@ export interface UnifiedAccountData { nationalFormat?: string | null; available: boolean; } | null; + passkeys: Passkey[]; // Connected services attachedClients: AttachedClient[]; @@ -91,6 +93,7 @@ export type ExtendedAccountState = Pick< | 'linkedAccounts' | 'subscriptions' | 'securityEvents' + | 'passkeys' | 'isLoading' | 'loadingFields' | 'error' @@ -111,6 +114,7 @@ const defaultExtendedState: ExtendedAccountState = { linkedAccounts: [], subscriptions: [], securityEvents: [], + passkeys: [], isLoading: false, loadingFields: [], error: null, @@ -262,6 +266,7 @@ export function getExtendedAccountState(uid?: string): ExtendedAccountState { linkedAccounts: account.linkedAccounts, subscriptions: account.subscriptions, securityEvents: account.securityEvents, + passkeys: account.passkeys, isLoading: account.isLoading, loadingFields: account.loadingFields, error: account.error, @@ -346,6 +351,7 @@ export function getFullAccountData(uid?: string): { linkedAccounts: LinkedAccount[]; subscriptions: { created: number; productName: string }[]; securityEvents: SecurityEvent[]; + passkeys: Passkey[]; } | null { const account = getAccountData(uid); if (!account) return null; @@ -372,6 +378,7 @@ export function getFullAccountData(uid?: string): { linkedAccounts: account.linkedAccounts, subscriptions: account.subscriptions, securityEvents: account.securityEvents, + passkeys: account.passkeys, }; } diff --git a/packages/fxa-settings/src/lib/hooks/useAccountData.ts b/packages/fxa-settings/src/lib/hooks/useAccountData.ts index 423f152a74c..2a07a4a2c37 100644 --- a/packages/fxa-settings/src/lib/hooks/useAccountData.ts +++ b/packages/fxa-settings/src/lib/hooks/useAccountData.ts @@ -21,6 +21,7 @@ import { AccountTotp, AccountBackupCodes, AccountAvatar } from '../interfaces'; import config from '../config'; import { ERRNO } from '@fxa/accounts/errors'; import * as Sentry from '@sentry/browser'; +import type { Passkey } from 'fxa-auth-client/browser'; /** OAuth token TTL in seconds for profile server requests */ const PROFILE_OAUTH_TOKEN_TTL_SECONDS = 300; @@ -90,6 +91,7 @@ interface AccountResponse { createdAt: number; verified?: boolean; }>; + passkeys?: Passkey[]; metricsOptOutAt?: number | null; createdAt?: number; passwordCreatedAt?: number; @@ -149,6 +151,8 @@ function transformAccountResponse( }) ); + const passkeys: Passkey[] = response.passkeys || []; + return { emails, linkedAccounts, @@ -162,6 +166,7 @@ function transformAccountResponse( recoveryKey, recoveryPhone, securityEvents, + passkeys, }; } @@ -314,6 +319,11 @@ export function useAccountData({ fieldData.attachedClients = clients.map(mapAttachedClient); break; } + case 'passkeys': { + const passkeys = await client.listPasskeys(token); + fieldData.passkeys = passkeys; + break; + } case 'displayName': case 'avatar': { const { displayName, avatar } = await fetchProfileData( diff --git a/packages/fxa-settings/src/models/Account.ts b/packages/fxa-settings/src/models/Account.ts index 66722908656..6c7c19b7ebb 100644 --- a/packages/fxa-settings/src/models/Account.ts +++ b/packages/fxa-settings/src/models/Account.ts @@ -13,6 +13,7 @@ import AuthClient, { getKeysV2, AttachedClient as RawAttachedClient, } from 'fxa-auth-client/browser'; +import type { Passkey } from 'fxa-auth-client/browser'; import { MetricsContext } from '@fxa/shared/glean'; import { currentAccount, @@ -184,6 +185,7 @@ export interface AccountData { }; subscriptions: Subscription[]; securityEvents: SecurityEvent[]; + passkeys: Passkey[]; } export interface ProfileInfo { @@ -314,6 +316,7 @@ export class Account implements AccountData { }, subscriptions: [], securityEvents: [], + passkeys: [], } as AccountData; } const error = new Error('Account data not loaded from localStorage'); @@ -354,6 +357,7 @@ export class Account implements AccountData { nationalFormat: null, available: false, }, + passkeys: accountData.passkeys || [], } as AccountData; } @@ -448,6 +452,10 @@ export class Account implements AccountData { return this.data.securityEvents; } + get passkeys() { + return this.data.passkeys; + } + get hasSecondaryVerifiedEmail() { return this.emails.length > 1 && this.emails[1].verified; } @@ -462,6 +470,7 @@ export class Account implements AccountData { | 'backupCodes' | 'recoveryPhone' | 'emails' + | 'passkeys' ) { const token = sessionToken(); if (!token) return; @@ -546,6 +555,10 @@ export class Account implements AccountData { })), }); break; + case 'passkeys': + const passkeys = await this.authClient.listPasskeys(token); + updateExtendedAccountState({ passkeys }); + break; case 'securityEvents': const events = await this.authClient.securityEvents(token); updateExtendedAccountState({ @@ -1498,6 +1511,30 @@ export class Account implements AccountData { }); } + /** + * Deletes the passkey identified by `credentialId`. + * Requires a cached MFA JWT with scope `passkey`. + */ + async deletePasskey(credentialId: string): Promise { + const jwt = this.getCachedJwtByScope('passkey'); + await this.withLoadingStatus( + this.authClient.deletePasskey(jwt, credentialId) + ); + await this.refresh('passkeys'); + } + + /** + * Renames the passkey identified by `credentialId`. + * Requires a cached MFA JWT with scope `passkey`. + */ + async renamePasskey(credentialId: string, name: string): Promise { + const jwt = this.getCachedJwtByScope('passkey'); + await this.withLoadingStatus( + this.authClient.renamePasskey(jwt, credentialId, name) + ); + await this.refresh('passkeys'); + } + /** * Checks for a JWT token in cache for the given scope. * @param scope MfaScope diff --git a/packages/fxa-settings/src/models/contexts/AccountStateContext.tsx b/packages/fxa-settings/src/models/contexts/AccountStateContext.tsx index ce7edb0237b..735e1d7499f 100644 --- a/packages/fxa-settings/src/models/contexts/AccountStateContext.tsx +++ b/packages/fxa-settings/src/models/contexts/AccountStateContext.tsx @@ -20,6 +20,7 @@ import { LinkedAccount, SecurityEvent, } from '../Account'; +import type { Passkey } from 'fxa-auth-client/browser'; import { useLocalStorageSync } from '../../lib/hooks/useLocalStorageSync'; import * as Sentry from '@sentry/browser'; import { @@ -64,6 +65,7 @@ export interface ExtendedAccountState { linkedAccounts: LinkedAccount[]; subscriptions: Subscription[]; securityEvents: SecurityEvent[]; + passkeys: Passkey[]; } export interface AccountState extends ExtendedAccountState { @@ -111,6 +113,7 @@ const defaultAccountState: AccountState = { linkedAccounts: [], subscriptions: [], securityEvents: [], + passkeys: [], isLoading: false, loadingFields: new Set(), error: null, @@ -164,6 +167,7 @@ function unifiedToAccountState( linkedAccounts: data.linkedAccounts, subscriptions: data.subscriptions, securityEvents: data.securityEvents, + passkeys: data.passkeys, isLoading: data.isLoading, loadingFields, error, @@ -195,7 +199,9 @@ export function AccountStateProvider({ // useLocalStorageSync triggers re-renders on storage changes; // we read from getAccountData() rather than the return value directly useLocalStorageSync('accounts'); - let currentAccountUid = useLocalStorageSync('currentAccountUid') as string | undefined; + let currentAccountUid = useLocalStorageSync('currentAccountUid') as + | string + | undefined; // Recover UID when missing (iOS WKWebView storage eviction) if (!currentAccountUid) { @@ -234,8 +240,12 @@ export function AccountStateProvider({ ...rest, ...(dataUid !== undefined && { uid: dataUid ?? undefined }), ...(dataEmail !== undefined && { email: dataEmail ?? undefined }), - loadingFields: data.loadingFields ? Array.from(data.loadingFields) : undefined, - error: data.error ? { message: data.error.message, name: data.error.name } : undefined, + loadingFields: data.loadingFields + ? Array.from(data.loadingFields) + : undefined, + error: data.error + ? { message: data.error.message, name: data.error.name } + : undefined, }; updateAccountData(storageData, uid); }, []); @@ -245,14 +255,18 @@ export function AccountStateProvider({ const uid = getCurrentAccountUid(); if (!uid || field === 'primaryEmail') return; - let storageValue: UnifiedAccountData[keyof UnifiedAccountData] = value as UnifiedAccountData[keyof UnifiedAccountData]; + let storageValue: UnifiedAccountData[keyof UnifiedAccountData] = + value as UnifiedAccountData[keyof UnifiedAccountData]; if (field === 'loadingFields' && value instanceof Set) { storageValue = Array.from(value); } if (field === 'error' && value instanceof Error) { storageValue = { message: value.message, name: value.name }; } - updateAccountData({ [field]: storageValue } as Partial, uid); + updateAccountData( + { [field]: storageValue } as Partial, + uid + ); }, [] ); @@ -282,9 +296,12 @@ export function AccountStateProvider({ const setError = useCallback((error: Error | null) => { const uid = getCurrentAccountUid(); if (!uid) return; - updateAccountData({ - error: error ? { message: error.message, name: error.name } : null, - }, uid); + updateAccountData( + { + error: error ? { message: error.message, name: error.name } : null, + }, + uid + ); }, []); const clearAccount = useCallback(() => { diff --git a/packages/fxa-settings/src/models/contexts/AppContext.ts b/packages/fxa-settings/src/models/contexts/AppContext.ts index 1ea36841842..7db5e342be8 100644 --- a/packages/fxa-settings/src/models/contexts/AppContext.ts +++ b/packages/fxa-settings/src/models/contexts/AppContext.ts @@ -132,6 +132,7 @@ export function defaultAppContext(context?: AppContextValue) { }, linkedAccounts: [], securityEvents: [], + passkeys: [], recoveryPhone: { exists: false, phoneNumber: null,