diff --git a/packages/fxa-settings/src/components/App/index.test.tsx b/packages/fxa-settings/src/components/App/index.test.tsx index a62a39a456d..42575d0f3c6 100644 --- a/packages/fxa-settings/src/components/App/index.test.tsx +++ b/packages/fxa-settings/src/components/App/index.test.tsx @@ -4,10 +4,15 @@ import React, { ReactNode } from 'react'; import { render, act } from '@testing-library/react'; +import { History } from '@reach/router'; import App from '.'; import * as Metrics from '../../lib/metrics'; import { Account, AppContext, useInitialState } from '../../models'; -import { mockAppContext, renderWithRouter } from '../../models/mocks'; +import { + mockAppContext, + MOCK_ACCOUNT, + renderWithRouter, +} from '../../models/mocks'; import { Config } from '../../lib/config'; import * as NavTiming from 'fxa-shared/metrics/navigation-timing'; import { HomePath } from '../../constants'; @@ -51,7 +56,7 @@ describe('metrics', () => { }); it('Initializes metrics flow data when present', async () => { - (useInitialState as jest.Mock).mockReturnValueOnce({loading: true}); + (useInitialState as jest.Mock).mockReturnValueOnce({ loading: true }); const DEVICE_ID = 'yoyo'; const BEGIN_TIME = 123456; const FLOW_ID = 'abc123'; @@ -93,9 +98,10 @@ describe('performance metrics', () => { }); it('observeNavigationTiming is called when metrics collection is enabled', () => { - (useInitialState as jest.Mock).mockReturnValueOnce({loading: true}); + (useInitialState as jest.Mock).mockReturnValueOnce({ loading: true }); const account = { metricsEnabled: true, + hasPassword: true, } as unknown as Account; render( @@ -106,9 +112,10 @@ describe('performance metrics', () => { }); it('observeNavigationTiming is not called when metrics collection is disabled', () => { - (useInitialState as jest.Mock).mockReturnValueOnce({loading: true}); + (useInitialState as jest.Mock).mockReturnValueOnce({ loading: true }); const account = { metricsEnabled: false, + hasPassword: true, } as unknown as Account; render( @@ -132,18 +139,24 @@ describe('App component', () => { expect(getByLabelText('Loading...')).toBeInTheDocument(); }); - it('renders `LoadingSpinner` component when the error message includes "Invalid token"',() => { - (useInitialState as jest.Mock).mockReturnValueOnce({ error: { message: "Invalid token" }}); + it('renders `LoadingSpinner` component when the error message includes "Invalid token"', () => { + (useInitialState as jest.Mock).mockReturnValueOnce({ + error: { message: 'Invalid token' }, + }); const { getByLabelText } = renderWithRouter(); expect(getByLabelText('Loading...')).toBeInTheDocument(); }); it('renders `AppErrorDialog` component when there is an error other than "Invalid token"', () => { - (useInitialState as jest.Mock).mockReturnValueOnce({ error: { message: "Error" }}); + (useInitialState as jest.Mock).mockReturnValueOnce({ + error: { message: 'Error' }, + }); const { getByRole } = renderWithRouter(); - expect(getByRole('heading', { level: 2 })).toHaveTextContent('General application error'); + expect(getByRole('heading', { level: 2 })).toHaveTextContent( + 'General application error' + ); }); (useInitialState as jest.Mock).mockReturnValue({ loading: false }); @@ -242,7 +255,7 @@ describe('App component', () => { history: { navigate }, } = renderWithRouter(, { route: HomePath }); - await navigate(HomePath + '/two_step_authentication/replace_codes' ); + await navigate(HomePath + '/two_step_authentication/replace_codes'); expect(getByTestId('2fa-recovery-codes')).toBeInTheDocument(); }); @@ -279,4 +292,43 @@ describe('App component', () => { expect(history.location.pathname).toBe('/settings/avatar'); }); + + describe('prevents access to certain routes when account has no password', () => { + let history: History; + + beforeEach(async () => { + const account = { + ...MOCK_ACCOUNT, + hasPassword: false, + } as unknown as Account; + + const config = { + metrics: { navTiming: { enabled: true, endpoint: '/foobar' } }, + } as Config; + + ({ history } = await renderWithRouter( + + + , + { route: HomePath } + )); + }); + + it('redirects PageRecoveryKeyAdd', async () => { + await history.navigate(HomePath + '/account_recovery'); + expect(history.location.pathname).toBe('/settings'); + }); + + it('redirects PageTwoStepAuthentication', async () => { + await history.navigate(HomePath + '/two_step_authentication'); + expect(history.location.pathname).toBe('/settings'); + }); + + it('redirects Page2faReplaceRecoveryCodes', async () => { + await history.navigate( + HomePath + '/two_step_authentication/replace_codes' + ); + expect(history.location.pathname).toBe('/settings'); + }); + }); }); diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 189fd5ed0f1..202a39e275e 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -32,7 +32,7 @@ type AppProps = { export const App = ({ flowQueryParams, navigatorLanguages }: AppProps) => { const config = useConfig(); - const { metricsEnabled } = useAccount(); + const { metricsEnabled, hasPassword } = useAccount(); useEffect(() => { if (config.metrics.navTiming.enabled && metricsEnabled) { @@ -76,11 +76,31 @@ export const App = ({ flowQueryParams, navigatorLanguages }: AppProps) => { - + {hasPassword ? ( + + ) : ( + + )} - - + {hasPassword ? ( + + ) : ( + + )} + {hasPassword ? ( + + ) : ( + + )} { const { l10n } = useLocalization(); const localizedCtaAdd = l10n.getString( @@ -158,46 +162,66 @@ export const UnitRow = ({
- {!hideCtaText && route && ( - - {ctaText} - - )} + {!hideCtaText && ctaText} + + ) : ( + <> + {!hideCtaText && route && ( + + {ctaText} + + )} - {revealModal && ( - - )} + {revealModal && ( + + )} - {secondaryCtaRoute && ( - - {secondaryCtaText} - - )} + {secondaryCtaRoute && ( + + {secondaryCtaText} + + )} - {revealSecondaryModal && ( - + {revealSecondaryModal && ( + + )} + )} - {actionContent}
diff --git a/packages/fxa-settings/src/components/UnitRowRecoveryKey/index.test.tsx b/packages/fxa-settings/src/components/UnitRowRecoveryKey/index.test.tsx index 05392bcff5b..754678db5a2 100644 --- a/packages/fxa-settings/src/components/UnitRowRecoveryKey/index.test.tsx +++ b/packages/fxa-settings/src/components/UnitRowRecoveryKey/index.test.tsx @@ -16,6 +16,7 @@ import { Account, AppContext } from '../../models'; import * as Metrics from '../../lib/metrics'; const account = { + hasPassword: true, recoveryKey: true, deleteRecoveryKey: jest.fn().mockResolvedValue(true), } as unknown as Account; @@ -40,6 +41,7 @@ describe('UnitRowRecoveryKey', () => { it('renders when recovery key is not set', () => { const account = { + hasPassword: true, recoveryKey: false, } as unknown as Account; renderWithRouter( @@ -58,8 +60,34 @@ describe('UnitRowRecoveryKey', () => { ).toContain('Create'); }); + it('renders disabled state when account has no password', () => { + const account = { + hasPassword: false, + recoveryKey: false, + } as unknown as Account; + + renderWithRouter( + + + + ); + + expect( + screen.getByTestId('recovery-key-unit-row-route').textContent + ).toContain('Create'); + + expect( + screen + .getByTestId('recovery-key-unit-row-route') + .attributes.getNamedItem('title')?.value + ).toEqual( + 'Set a password to use Firefox Sync and certain account security features.' + ); + }); + it('can be refreshed', async () => { const account = { + hasPassword: true, recoveryKey: false, refresh: jest.fn(), } as unknown as Account; @@ -98,6 +126,7 @@ describe('UnitRowRecoveryKey', () => { const removeRecoveryKey = async (process = false) => { const account = { + hasPassword: true, recoveryKey: true, } as unknown as Account; diff --git a/packages/fxa-settings/src/components/UnitRowRecoveryKey/index.tsx b/packages/fxa-settings/src/components/UnitRowRecoveryKey/index.tsx index 27418cdeb15..bb4e4dad8c6 100644 --- a/packages/fxa-settings/src/components/UnitRowRecoveryKey/index.tsx +++ b/packages/fxa-settings/src/components/UnitRowRecoveryKey/index.tsx @@ -54,6 +54,12 @@ export const UnitRowRecoveryKey = () => { ? l10n.getString('rk-action-remove', null, 'Remove') : l10n.getString('rk-action-create', null, 'Create') } + disabled={!account.hasPassword} + disabledReason={l10n.getString( + 'security-set-password', + null, + 'Set a password to use Firefox Sync and certain account security features.' + )} alertBarRevealed headerContent={ { title={l10n.getString('rk-refresh-key')} classNames="hidden mobileLandscape:inline-block ltr:ml-1 rtl:mr-1" testId="recovery-key-refresh" - disabled={account.loading} + disabled={account.loading || !account.hasPassword} onClick={() => account.refresh('recovery')} /> } diff --git a/packages/fxa-settings/src/components/UnitRowTwoStepAuth/index.test.tsx b/packages/fxa-settings/src/components/UnitRowTwoStepAuth/index.test.tsx index ee6bdd9ee98..e2cff83daa5 100644 --- a/packages/fxa-settings/src/components/UnitRowTwoStepAuth/index.test.tsx +++ b/packages/fxa-settings/src/components/UnitRowTwoStepAuth/index.test.tsx @@ -10,6 +10,7 @@ import { Account, AppContext } from '../../models'; jest.mock('../../models/AlertBarInfo'); const account = { + hasPassword: true, totp: { exists: true, verified: true }, disableTwoStepAuth: jest.fn().mockResolvedValue(true), } as unknown as Account; @@ -50,6 +51,7 @@ describe('UnitRowTwoStepAuth', () => { it('renders when Two-step authentication is not enabled', () => { const account = { + hasPassword: true, totp: { exists: false, verified: false }, } as unknown as Account; renderWithRouter( @@ -70,6 +72,7 @@ describe('UnitRowTwoStepAuth', () => { it('can be refreshed', async () => { const account = { + hasPassword: true, totp: { exists: false, verified: false }, refresh: jest.fn(), } as unknown as Account; @@ -111,4 +114,28 @@ describe('UnitRowTwoStepAuth', () => { expect(context.alertBarInfo?.success).toBeCalledTimes(1); }); + + it('renders disabled state when account has no password', async () => { + const account = { + hasPassword: false, + totp: { exists: false, verified: false }, + } as unknown as Account; + + renderWithRouter( + + + + ); + + expect(screen.getByTestId('two-step-unit-row-route').textContent).toContain( + 'Add' + ); + expect( + screen + .getByTestId('two-step-unit-row-route') + .attributes.getNamedItem('title')?.value + ).toEqual( + 'Set a password to use Firefox Sync and certain account security features.' + ); + }); }); diff --git a/packages/fxa-settings/src/components/UnitRowTwoStepAuth/index.tsx b/packages/fxa-settings/src/components/UnitRowTwoStepAuth/index.tsx index 785cb2dbc67..7924783fb0c 100644 --- a/packages/fxa-settings/src/components/UnitRowTwoStepAuth/index.tsx +++ b/packages/fxa-settings/src/components/UnitRowTwoStepAuth/index.tsx @@ -93,13 +93,19 @@ export const UnitRowTwoStepAuth = () => { /> } + disabled={!account.hasPassword} + disabledReason={l10n.getString( + 'security-set-password', + null, + 'Set a password to use Firefox Sync and certain account security features.' + )} actionContent={ account.refresh('totp')} /> diff --git a/packages/fxa-settings/src/models/Account.ts b/packages/fxa-settings/src/models/Account.ts index 193424c078d..903e02d76b6 100644 --- a/packages/fxa-settings/src/models/Account.ts +++ b/packages/fxa-settings/src/models/Account.ts @@ -53,6 +53,7 @@ export interface AccountData { }; accountCreated: number; passwordCreated: number; + hasPassword: boolean; recoveryKey: boolean; metricsEnabled: boolean; primaryEmail: Email; @@ -273,6 +274,18 @@ export class Account implements AccountData { return this.data.passwordCreated; } + get hasPassword() { + // This might be requested before account data is ready, + // so default to disabled until we can get a proper read + try { + return ( + this.data?.passwordCreated != null && this.data.passwordCreated > 0 + ); + } catch { + return false; + } + } + get recoveryKey() { return this.data.recoveryKey; } diff --git a/packages/fxa-settings/src/models/mocks.tsx b/packages/fxa-settings/src/models/mocks.tsx index 5f4464a53a1..67a0f749712 100644 --- a/packages/fxa-settings/src/models/mocks.tsx +++ b/packages/fxa-settings/src/models/mocks.tsx @@ -24,6 +24,7 @@ export const MOCK_ACCOUNT: AccountData = { }, accountCreated: 123456789, passwordCreated: 123456789, + hasPassword: true, recoveryKey: true, metricsEnabled: true, attachedClients: [],