From 761ed3d22d4f2d32b444296748fc031f50360155 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Tue, 10 Feb 2026 19:19:30 -0500 Subject: [PATCH] feat(settings): Migrate fxa-settings from GraphQL to direct auth-client calls --- .../tests/settings/changeEmail.spec.ts | 23 +- .../tests/settings/multitab.spec.ts | 164 --- .../tests/syncV3/settings.spec.ts | 20 +- packages/fxa-auth-client/lib/client.ts | 38 +- .../server/lib/beta-settings.js | 16 +- .../server/lib/configuration.js | 8 +- .../server/lib/csp/blocking.js | 3 - packages/fxa-graphql-api/test/app.e2e-spec.ts | 3 +- packages/fxa-settings/package.json | 3 +- packages/fxa-settings/public/index.html | 1 - .../src/components/App/index.test.tsx | 30 +- .../fxa-settings/src/components/App/index.tsx | 35 +- .../src/components/App/interfaces.ts | 15 +- .../components/LegalWithMarkdown/index.tsx | 14 +- .../Settings/AlertBar/index.test.tsx | 4 +- .../components/Settings/AlertBar/index.tsx | 26 +- .../Settings/ConnectedServices/index.tsx | 9 +- .../Settings/DropDownAvatarMenu/index.tsx | 4 +- .../components/Settings/MfaGuard/index.tsx | 17 +- .../Settings/ModalVerifySession/index.tsx | 43 +- .../Page2faReplaceBackupCodes/index.tsx | 8 +- .../PageChangePassword/index.test.tsx | 6 + .../Settings/PageDeleteAccount/index.test.tsx | 23 +- .../Settings/PageDeleteAccount/index.tsx | 8 +- .../PageMfaGuardWithGqlTest/index.tsx | 182 --- .../PageSecondaryEmailAdd/index.test.tsx | 7 + .../UnitRowRecoveryKey/index.test.tsx | 16 +- .../UnitRowTwoStepAuth/index.test.tsx | 6 + .../VerifiedSessionGuard/index.test.tsx | 38 + .../Settings/VerifiedSessionGuard/index.tsx | 43 +- .../src/components/Settings/index.test.tsx | 81 +- .../src/components/Settings/index.tsx | 62 +- packages/fxa-settings/src/index.tsx | 9 +- .../fxa-settings/src/lib/account-storage.ts | 10 +- ...upgrade.ts => auth-key-stretch-upgrade.ts} | 145 +-- packages/fxa-settings/src/lib/cache.ts | 114 +- packages/fxa-settings/src/lib/config.ts | 32 +- packages/fxa-settings/src/lib/error-utils.ts | 34 - .../fxa-settings/src/lib/file-utils-legal.tsx | 118 +- packages/fxa-settings/src/lib/gql.ts | 4 +- .../src/lib/hooks/useAccountData.ts | 51 +- .../src/lib/hooks/useTotpReplace/index.tsx | 8 +- .../src/lib/hooks/useTotpSetup/index.tsx | 10 +- .../src/lib/sensitive-data-client.ts | 2 +- .../fxa-settings/src/lib/storage-utils.ts | 13 +- packages/fxa-settings/src/models/Account.ts | 1065 ++++++++--------- .../fxa-settings/src/models/AlertBarInfo.ts | 2 +- packages/fxa-settings/src/models/Legal.ts | 17 - packages/fxa-settings/src/models/Session.ts | 146 +-- .../src/models/contexts/AppContext.ts | 43 +- .../src/models/contexts/SettingsContext.ts | 134 +-- .../fxa-settings/src/models/hooks.test.ts | 3 - packages/fxa-settings/src/models/hooks.ts | 303 ++++- packages/fxa-settings/src/models/index.ts | 3 +- packages/fxa-settings/src/models/mocks.tsx | 1 + .../pages/Authorization/container.test.tsx | 1 + .../src/pages/Authorization/container.tsx | 2 - .../container.test.tsx | 60 +- .../InlineRecoverySetupFlow/container.tsx | 55 +- .../pages/InlineTotpSetup/container.test.tsx | 120 +- .../src/pages/InlineTotpSetup/container.tsx | 78 +- .../ThirdPartyAuthCallback/index.tsx | 1 + .../CompleteResetPassword/container.tsx | 1 + .../pages/Signin/SigninPushCode/container.tsx | 2 +- .../SigninRecoveryCode/container.test.tsx | 64 +- .../Signin/SigninRecoveryCode/container.tsx | 37 +- .../pages/Signin/SigninRecoveryCode/index.tsx | 21 +- .../pages/Signin/SigninRecoveryCode/mocks.tsx | 26 - .../SigninRecoveryPhone/container.test.tsx | 2 + .../Signin/SigninRecoveryPhone/container.tsx | 16 +- .../Signin/SigninTokenCode/container.test.tsx | 31 +- .../Signin/SigninTokenCode/container.tsx | 35 +- .../Signin/SigninTotpCode/container.test.tsx | 46 +- .../pages/Signin/SigninTotpCode/container.tsx | 73 +- .../src/pages/Signin/SigninTotpCode/index.tsx | 1 + .../Signin/SigninUnblock/container.test.tsx | 162 ++- .../pages/Signin/SigninUnblock/container.tsx | 95 +- .../src/pages/Signin/SigninUnblock/index.tsx | 4 +- .../src/pages/Signin/SigninUnblock/mocks.tsx | 2 - .../src/pages/Signin/container.test.tsx | 499 ++++---- .../src/pages/Signin/container.tsx | 173 +-- .../fxa-settings/src/pages/Signin/index.tsx | 3 +- .../fxa-settings/src/pages/Signin/mocks.tsx | 216 ---- .../fxa-settings/src/pages/Signin/utils.ts | 16 +- .../ConfirmSignupCode/container.test.tsx | 77 +- .../Signup/ConfirmSignupCode/container.tsx | 56 +- .../pages/Signup/ConfirmSignupCode/index.tsx | 1 + .../Signup/ConfirmSignupCode/interfaces.ts | 4 - .../src/pages/Signup/container.test.tsx | 30 +- .../src/pages/Signup/container.tsx | 2 +- .../fxa-settings/src/pages/Signup/index.tsx | 2 + packages/fxa-settings/src/setupTests.tsx | 12 + yarn.lock | 3 +- 93 files changed, 2285 insertions(+), 2967 deletions(-) delete mode 100644 packages/functional-tests/tests/settings/multitab.spec.ts delete mode 100644 packages/fxa-settings/src/components/Settings/PageMfaGuardWithGqlTest/index.tsx rename packages/fxa-settings/src/lib/{gql-key-stretch-upgrade.ts => auth-key-stretch-upgrade.ts} (61%) delete mode 100644 packages/fxa-settings/src/models/Legal.ts diff --git a/packages/functional-tests/tests/settings/changeEmail.spec.ts b/packages/functional-tests/tests/settings/changeEmail.spec.ts index 963fc04ac2c..70e485b38a3 100644 --- a/packages/functional-tests/tests/settings/changeEmail.spec.ts +++ b/packages/functional-tests/tests/settings/changeEmail.spec.ts @@ -71,7 +71,7 @@ test.describe('severity-1 #smoke', () => { initialPassword, newPassword, target, - credentials.email + newEmail ); credentials.password = newPassword; @@ -119,7 +119,7 @@ test.describe('severity-1 #smoke', () => { initialPassword, newPassword, target, - credentials.email + secondEmail ); credentials.password = newPassword; @@ -130,9 +130,16 @@ test.describe('severity-1 #smoke', () => { await signin.fillOutEmailFirstForm(secondEmail); await signin.fillOutPasswordForm(newPassword); - // Change back the primary email again + // Clear stale MFA OTP codes from the password change step + await target.emailClient.clear(secondEmail); + + // makePrimaryButton is wrapped in MfaGuard(scope="email"). + // After sign-out + sign-in, the JWT cache is cleared, so the MFA modal appears. await settings.secondaryEmail.makePrimaryButton.click(); - await settings.confirmMfaGuard(credentials.email); + await settings.confirmMfaGuard(secondEmail); + await expect(settings.alertBar).toHaveText( + new RegExp(`${initialEmail}.*is now your primary email`) + ); await settings.signOut(); // Login with primary email and new password @@ -141,8 +148,7 @@ test.describe('severity-1 #smoke', () => { await expect(settings.settingsHeading).toBeVisible(); - console.log('credentials.password', credentials.password); - // Update which password to use the account cleanup + // Update which password to use for account cleanup credentials.password = newPassword; }); @@ -175,6 +181,8 @@ test.describe('severity-1 #smoke', () => { await settings.deleteAccountButton.click(); await deleteAccount.deleteAccount(credentials.password); + await expect(page.getByText('Account deleted successfully')).toBeVisible(); + // Try creating a new account with the same secondary email as previous account and new password await signup.fillOutEmailForm(newEmail); await signup.fillOutSignupForm(newPassword); @@ -290,6 +298,9 @@ async function setNewPassword( ): Promise { await settings.password.changeButton.click(); + // PageChangePassword is wrapped in MfaGuard(scope="password"). + // signUpAndPrimeMfa only primes the "email" scope JWT, so the + // "password" scope JWT is never cached and the MFA modal always appears. await settings.confirmMfaGuard(email); await changePassword.fillOutChangePassword(oldPassword, newPassword); diff --git a/packages/functional-tests/tests/settings/multitab.spec.ts b/packages/functional-tests/tests/settings/multitab.spec.ts deleted file mode 100644 index 65c415f996e..00000000000 --- a/packages/functional-tests/tests/settings/multitab.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * 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 { BrowserContext } from '@playwright/test'; -import { expect, Page, test } from '../../lib/fixtures/standard'; -import { BaseTarget } from '../../lib/targets/base'; - -test.describe('severity-1 #smoke', () => { - test('settings opens in multiple tabs with same account', async ({ - context, - target, - page, - pages: { signin, settings }, - testAccountTracker, - }) => { - const pages = [signin, settings]; - const credentials1 = await testAccountTracker.signUp(); - await page.goto(target.contentServerUrl); - await signin.fillOutEmailFirstForm(credentials1.email); - await signin.fillOutPasswordForm(credentials1.password); - await expect(settings.settingsHeading).toBeVisible(); - - // Signin on new tab, and then sign out - await openNewTab(context, target, pages); - await signin.signInButton.click(); - await expect(settings.settingsHeading).toBeVisible(); - await settings.signOut(); - await expect(signin.emailFirstHeading).toBeVisible(); - - // Switch focus to 1st tab. Page should logout, since - // account on other tab logged out. - await activateTab(page, pages); - await expect(signin.emailFirstHeading).toBeVisible(); - }); - - test('settings opens in multiple tabs with different accounts', async ({ - context, - target, - page, - pages: { signin, settings, signup, confirmSignupCode }, - testAccountTracker, - }) => { - const pages = [signin, settings, signup, confirmSignupCode]; - const credentials = await testAccountTracker.signUp(); - await page.goto(target.contentServerUrl); - await signin.fillOutEmailFirstForm(credentials.email); - await signin.fillOutPasswordForm(credentials.password); - await expect(settings.settingsHeading).toBeVisible(); - - // Signup on new tab - const email2 = credentials.email.replace('@', '.2@'); - const password2 = credentials.password; - - const newTab = await openNewTab(context, target, pages); - await signin.useDifferentAccountLink.click(); - - await signup.fillOutEmailForm(email2); - await signup.fillOutSignupForm(password2); - const code = await target.emailClient.getVerifyShortCode(email2); - await expect(newTab).toHaveURL(/confirm_signup_code/); - await confirmSignupCode.fillOutCodeForm(code); - await expect(settings.settingsHeading).toBeVisible(); - - // Signout of 2nd tab - await settings.signOut(); - await expect(page.getByText(credentials.email)).toBeVisible(); - - // Switch focus back to 1st tab. Page should NOT logout, - // since this account is unaffected - await activateTab(page, pages); - await expect(settings.settingsHeading).toBeVisible(); - }); - - test('settings opens in multiple tabs user clears local storage', async ({ - context, - target, - page, - pages: { signin, settings }, - testAccountTracker, - }) => { - const pages = [signin, settings]; - const credentials = await testAccountTracker.signUp(); - await page.goto(target.contentServerUrl); - await signin.fillOutEmailFirstForm(credentials.email); - await signin.fillOutPasswordForm(credentials.password); - await expect(settings.settingsHeading).toBeVisible(); - - // Open new tab, and clear localstorage - const newPage = await openNewTab(context, target, pages); - await newPage.evaluate(() => { - localStorage.removeItem('__fxa_storage.accounts'); - }); - - // Switch focus to 1st tab. Page should logout, since - // local storage has been wiped clean - await activateTab(page, pages); - await expect(signin.emailFirstHeading).toBeVisible(); - }); - - test('settings opens in multiple tabs and apollo account cache is dropped', async ({ - context, - target, - page, - pages: { signin, settings }, - testAccountTracker, - }) => { - test.skip( - !/localhost/.test(target.contentServerUrl), - 'Access to apollo client is only available during in dev mode, which requires running on localhost.' - ); - - const credentials = await testAccountTracker.signUp(); - await page.goto(target.contentServerUrl); - await signin.fillOutEmailFirstForm(credentials.email); - await signin.fillOutPasswordForm(credentials.password); - await expect(settings.settingsHeading).toBeVisible(); - - // Signin on new tab - await openNewTab(context, target, [signin, settings]); - await signin.signInButton.click(); - await expect(settings.settingsHeading).toBeVisible(); - - // Mutate apollo cache on page 1, and refocus - await page.evaluate(() => { - // @ts-ignore - const client = window.__APOLLO_CLIENT__; - if (client) { - client.cache.modify({ - id: 'ROOT_QUERY', - fields: { - account: () => { - return undefined; - }, - }, - broadcast: false, - }); - } - }); - - await activateTab(page, [signin, settings]); - await expect(signin.cachedSigninHeading).toBeVisible(); - }); - - async function openNewTab( - context: BrowserContext, - target: BaseTarget, - pages: Array<{ page: Page }> - ) { - const page = await context.newPage(); - pages.forEach((x) => { - x.page = page; - }); - await page.goto(target.contentServerUrl); - return page; - } - async function activateTab(page: Page, pages: Array<{ page: Page }>) { - pages.forEach((x) => { - x.page = page; - }); - await page.bringToFront(); - await page.dispatchEvent('body', 'focus'); - } -}); diff --git a/packages/functional-tests/tests/syncV3/settings.spec.ts b/packages/functional-tests/tests/syncV3/settings.spec.ts index d83c4ad0b9b..585a475d408 100644 --- a/packages/functional-tests/tests/syncV3/settings.spec.ts +++ b/packages/functional-tests/tests/syncV3/settings.spec.ts @@ -138,6 +138,7 @@ test.describe('severity-2 #smoke', () => { test('sign in, delete the account', async ({ target, syncBrowserPages: { + connectAnotherDevice, settings, deleteAccount, page, @@ -147,22 +148,39 @@ test.describe('severity-2 #smoke', () => { testAccountTracker, }) => { const credentials = await testAccountTracker.signUpSync(); + const customEventDetail: LinkAccountResponse = { + id: 'account_updates', + message: { + command: FirefoxCommand.LinkAccount, + data: { + ok: true, + }, + }, + }; await page.goto( `${target.contentServerUrl}?context=fx_desktop_v3&service=sync&action=email` ); + await signin.respondToWebChannelMessage(customEventDetail); await signin.fillOutEmailFirstForm(credentials.email); await signin.fillOutPasswordForm(credentials.password); await expect(page).toHaveURL(/signin_token_code/); + await signin.checkWebChannelMessage(FirefoxCommand.LinkAccount); + const code = await target.emailClient.getVerifyLoginCode( credentials.email ); await signinTokenCode.fillOutCodeForm(code); - await expect(page).toHaveURL(/pair/); + + await signin.checkWebChannelMessage(FirefoxCommand.Login); + + await expect(connectAnotherDevice.fxaConnected).toBeEnabled(); await settings.goto(); + await page.waitForURL(/settings/); + await expect(settings.settingsHeading).toBeVisible(); //Click Delete account await settings.deleteAccountButton.click(); await deleteAccount.deleteAccount(credentials.password); diff --git a/packages/fxa-auth-client/lib/client.ts b/packages/fxa-auth-client/lib/client.ts index 01d885bce80..ffc4bd89fd8 100644 --- a/packages/fxa-auth-client/lib/client.ts +++ b/packages/fxa-auth-client/lib/client.ts @@ -47,6 +47,38 @@ export type CredentialStatus = { clientSalt?: string; }; +export interface SecurityEvent { + name: string; + createdAt: number; + verified?: boolean; +} + +export interface AttachedClient { + clientId: string; + isCurrentSession: boolean; + userAgent: string; + deviceType: string | null; + deviceId: string | null; + name: string | null; + lastAccessTime: number; + lastAccessTimeFormatted: string; + approximateLastAccessTime: number | null; + approximateLastAccessTimeFormatted: string | null; + location?: { + city?: string | null; + country?: string | null; + state?: string | null; + stateCode?: string | null; + }; + os: string | null; + sessionTokenId: string | null; + refreshTokenId: string | null; +} + +export interface RecoveryKeyData { + recoveryData: string; +} + export type SignUpOptions = { keys?: boolean; service?: string; @@ -1874,11 +1906,11 @@ export default class AuthClient { return this.sessionGet('/account/sessions', sessionToken, headers); } - async securityEvents(sessionToken: hexstring, headers?: Headers) { + async securityEvents(sessionToken: hexstring, headers?: Headers): Promise { return this.sessionGet('/securityEvents', sessionToken, headers); } - async attachedClients(sessionToken: hexstring, headers?: Headers) { + async attachedClients(sessionToken: hexstring, headers?: Headers): Promise { return this.sessionGet('/account/attached_clients', sessionToken, headers); } @@ -2636,7 +2668,7 @@ export default class AuthClient { accountResetToken: hexstring, recoveryKeyId: string, headers?: Headers - ) { + ): Promise { return this.hawkRequest( 'GET', `/recoveryKey/${recoveryKeyId}`, diff --git a/packages/fxa-content-server/server/lib/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js index 22f7b6168c3..a9fb348d274 100644 --- a/packages/fxa-content-server/server/lib/beta-settings.js +++ b/packages/fxa-content-server/server/lib/beta-settings.js @@ -61,9 +61,6 @@ const settingsConfig = { serverName: config.get('sentry.serverName'), }, servers: { - gql: { - url: config.get('settings_gql_url'), - }, oauth: { url: config.get('fxaccount_url'), }, @@ -76,6 +73,9 @@ const settingsConfig = { paymentsNext: { url: config.get('payments_next_hosted_url'), }, + legalDocs: { + url: config.get('legal_docs_url'), + }, }, oauth: { clientId: config.get('oauth_client_id'), @@ -173,10 +173,7 @@ function preconnect(val) { function resolvePreConnectDirectives(settingsConfig) { // Using '?' will breaks l10n extraction :9 - let gqlUrl, authUrl, oauthUrl, sentryUrl; - try { - gqlUrl = settingsConfig.servers.gql.url; - } catch (e) {} + let authUrl, oauthUrl, sentryUrl; try { authUrl = settingsConfig.servers.auth.url; } catch (e) {} @@ -188,7 +185,6 @@ function resolvePreConnectDirectives(settingsConfig) { } catch (e) {} return { - __GQL_URL_PRECONNECT__: preconnect(gqlUrl), __AUTH_URL_PRECONNECT__: preconnect(authUrl), __OAUTH_URL_PRECONNECT__: preconnect(oauthUrl), __SENTRY_URL_PRECONNECT__: preconnect(sentryUrl), @@ -249,9 +245,9 @@ const createSettingsProxy = createProxyMiddleware({ // Modify the static settings page by replacing __SERVER_CONFIG__ with the config object const modifySettingsStatic = function (req, res) { if ( - process.env.NODE_ENV === 'development' && + ['development', 'test'].includes(process.env.NODE_ENV) && req.path.startsWith('/settings/') && - ['.js', '.css', '.ftl', '.json', '.svg'].includes(extname(req.path)) + ['.js', '.css', '.ftl', '.json', '.svg', '.md'].includes(extname(req.path)) ) { const filePath = join( settingsStaticPath, diff --git a/packages/fxa-content-server/server/lib/configuration.js b/packages/fxa-content-server/server/lib/configuration.js index 8d2b027a6a8..34c448ae449 100644 --- a/packages/fxa-content-server/server/lib/configuration.js +++ b/packages/fxa-content-server/server/lib/configuration.js @@ -440,10 +440,10 @@ const conf = (module.exports = convict({ format: String, }, }, - settings_gql_url: { - default: 'http://localhost:8290', - doc: 'The URL of the Firefox Account settings GraphQL server', - env: 'FXA_GQL_URL', + legal_docs_url: { + default: 'http://localhost:3030/settings/legal-docs', + doc: 'The base URL for fetching legal documents (privacy policy, terms of service)', + env: 'LEGAL_DOCS_URL', format: 'url', }, googleAuthConfig: { diff --git a/packages/fxa-content-server/server/lib/csp/blocking.js b/packages/fxa-content-server/server/lib/csp/blocking.js index 41604104a0b..9564951d015 100644 --- a/packages/fxa-content-server/server/lib/csp/blocking.js +++ b/packages/fxa-content-server/server/lib/csp/blocking.js @@ -25,7 +25,6 @@ module.exports = function (config) { const DATA = 'data:'; const DEFAULT_ALLOWED_IMG_SOURCES = config.get('csp.allowedImgSources'); const GLEAN_SERVER = getOrigin(config.get('glean.serverEndpoint')); - const GQL_SERVER = getOrigin(config.get('settings_gql_url')); const OAUTH_SERVER = getOrigin(config.get('oauth_url')); const PROFILE_SERVER = getOrigin(config.get('profile_url')); const PROFILE_IMAGES_SERVER = getOrigin(config.get('profile_images_url')); @@ -60,7 +59,6 @@ module.exports = function (config) { SELF, AUTH_SERVER, GLEAN_SERVER, - GQL_SERVER, OAUTH_SERVER, PROFILE_SERVER, PAIRING_SERVER_WEBSOCKET, @@ -127,7 +125,6 @@ module.exports = function (config) { CDN_URL, DATA, GLEAN_SERVER, - GQL_SERVER, NONE, OAUTH_SERVER, PAIRING_SERVER_HTTP, diff --git a/packages/fxa-graphql-api/test/app.e2e-spec.ts b/packages/fxa-graphql-api/test/app.e2e-spec.ts index 6fcf1ca6066..c12fed9afef 100644 --- a/packages/fxa-graphql-api/test/app.e2e-spec.ts +++ b/packages/fxa-graphql-api/test/app.e2e-spec.ts @@ -35,7 +35,8 @@ describe('AppController (e2e)', () => { .send({ operationName: null, variables: {}, - query: 'query GetUid {\n account {\n uid\n }\n}\n', + query: + 'query GetTotpStatus {\n account {\n totp {\n exists\n verified\n }\n }\n}\n', }) .expect(200); }); diff --git a/packages/fxa-settings/package.json b/packages/fxa-settings/package.json index d9229a61c9f..6742ec30d05 100644 --- a/packages/fxa-settings/package.json +++ b/packages/fxa-settings/package.json @@ -18,7 +18,6 @@ "clean": "rimraf dist", "copy-dev-build": "mkdir -p ../fxa-content-server/app/settings ; NODE_ENV=production yarn build-react-dev && cp -R build/dev ../fxa-content-server/app/settings", "compile": "tsc --noEmit", - "gql-extract": "persistgraphql src ../../configs/gql/allowlist/fxa-settings.json --js --extension=ts ", "l10n-bundle": "yarn l10n:bundle packages/fxa-settings branding,react,settings", "l10n-prime": "yarn l10n:prime packages/fxa-settings", "l10n-merge": "yarn grunt merge-ftl", @@ -36,6 +35,7 @@ "test-unit": "echo No unit tests present for $npm_package_name", "test-integration": "JEST_JUNIT_OUTPUT_FILE=../../artifacts/tests/$npm_package_name/fxa-settings-jest-integration-results.xml SKIP_PREFLIGHT_CHECK=true node scripts/test.js --watchAll=false --ci --runInBand --reporters=default --reporters=jest-junit", "watch-ftl": "grunt watch-ftl", + "gql-extract": "persistgraphql src ../../configs/gql/allowlist/fxa-settings.json --js --extension=ts", "format": "prettier --write --config ../../_dev/.prettierrc '**'" }, "jest": { @@ -184,7 +184,6 @@ "source-map-loader": "^5.0.0", "stream-browserify": "^3.0.0", "style-loader": "^4.0.0", - "subscriptions-transport-ws": "^0.11.0", "tailwindcss": "^3.4.1", "terser-webpack-plugin": "^5.2.5", "ua-parser-js": "1.0.35", diff --git a/packages/fxa-settings/public/index.html b/packages/fxa-settings/public/index.html index fafcf0dc602..7aadb77e4e3 100644 --- a/packages/fxa-settings/public/index.html +++ b/packages/fxa-settings/public/index.html @@ -12,7 +12,6 @@ content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=2,user-scalable=yes" /> - __GQL_URL_PRECONNECT__ __AUTH_URL_PRECONNECT__ __OAUTH_URL_PRECONNECT__ __SENTRY_URL_PRECONNECT__ diff --git a/packages/fxa-settings/src/components/App/index.test.tsx b/packages/fxa-settings/src/components/App/index.test.tsx index accbc5c42fe..727f0fff536 100644 --- a/packages/fxa-settings/src/components/App/index.test.tsx +++ b/packages/fxa-settings/src/components/App/index.test.tsx @@ -13,7 +13,6 @@ import { useInitialMetricsQueryState, useLocalSignedInQueryState, useIntegration, - useInitialSettingsState, useClientInfoState, useProductInfoState, useSession, @@ -53,13 +52,9 @@ jest.mock('fxa-shared/sentry/browser', () => ({ jest.mock('../../models/contexts/SettingsContext', () => ({ ...jest.requireActual('../../models/contexts/SettingsContext'), - initializeSettingsContext: jest.fn().mockImplementation(() => { - const context = { - alertBarInfo: jest.fn(), - navigatorLanguages: jest.fn(), - }; - - return context; + initializeSettingsContext: jest.fn().mockReturnValue({ + alertBarInfo: jest.fn(), + navigatorLanguages: jest.fn(), }), })); @@ -70,9 +65,11 @@ jest.mock('../../lib/channels/firefox', () => ({ }, })); +const mockSessionToken = jest.fn(); jest.mock('../../lib/cache', () => ({ ...jest.requireActual('../../lib/cache'), currentAccount: jest.fn(), + sessionToken: () => mockSessionToken(), })); const mockSessionStatus = jest.fn(); @@ -80,7 +77,6 @@ jest.mock('../../models', () => ({ ...jest.requireActual('../../models'), useInitialMetricsQueryState: jest.fn(), useLocalSignedInQueryState: jest.fn(), - useInitialSettingsState: jest.fn(), useClientInfoState: jest.fn(), useProductInfoState: jest.fn(), useIntegration: jest.fn(), @@ -100,6 +96,17 @@ jest.mock('../Settings/ScrollToTop', () => ({ ), })); +jest.mock('../../lib/hooks/useAccountData', () => ({ + __esModule: true, + useAccountData: jest.fn().mockReturnValue({ + isLoading: false, + error: null, + data: {}, + refetch: jest.fn(), + refetchField: jest.fn(), + }), +})); + jest.mock('../../lib/glean', () => ({ __esModule: true, default: { @@ -108,6 +115,7 @@ jest.mock('../../lib/glean', () => ({ useGlean: jest.fn().mockReturnValue({ enabled: true }), accountPref: { view: jest.fn(), promoMonitorView: jest.fn() }, emailFirst: { view: jest.fn(), engage: jest.fn() }, + error: { view: jest.fn() }, pageLoad: jest.fn(), }, })); @@ -394,6 +402,8 @@ describe('SettingsRoutes', () => { beforeEach(() => { jest.spyOn(ReactUtils, 'hardNavigate').mockImplementation(() => {}); jest.clearAllMocks(); + // Provide a session token for useAccountData hook + mockSessionToken.mockReturnValue('mockSessionToken123'); (useInitialMetricsQueryState as jest.Mock).mockReturnValue({ loading: false, }); @@ -419,7 +429,6 @@ describe('SettingsRoutes', () => { loading: false, data: {}, }); - (useInitialSettingsState as jest.Mock).mockReturnValue({ loading: false }); mockSessionStatus.mockResolvedValue({ details: { sessionVerified: true, @@ -432,7 +441,6 @@ describe('SettingsRoutes', () => { (useIntegration as jest.Mock).mockRestore(); (useInitialMetricsQueryState as jest.Mock).mockRestore(); (useLocalSignedInQueryState as jest.Mock).mockRestore(); - (useInitialSettingsState as jest.Mock).mockRestore(); (useProductInfoState as jest.Mock).mockRestore(); (firefox.requestSignedInUser as jest.Mock).mockRestore(); (useClientInfoState as jest.Mock).mockRestore(); diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index b9d58bf0215..28a1eabe5a3 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -36,6 +36,7 @@ import { initializeSettingsContext, SettingsContext, } from '../../models/contexts/SettingsContext'; +import { AccountStateProvider } from '../../models/contexts/AccountStateContext'; import sentryMetrics from 'fxa-shared/sentry/browser'; import { maybeRecordWebAuthnCapabilities } from '../../lib/webauthnCapabilitiesProbe'; @@ -197,7 +198,7 @@ export const App = ({ return; } - // If the local apollo cache says we are signed in, then we can skip the rest. + // If localStorage indicates we are signed in, we can skip the rest. if (isSignedInData?.isSignedIn === true) { startTransition(() => { setIsSignedIn(true); @@ -305,10 +306,10 @@ export const App = ({ Metrics.init(metricsEnabled, updatedFlowQueryParams); if (data?.account?.metricsEnabled) { Metrics.initUserPreferences({ - recoveryKey: data.account.recoveryKey.exists, + recoveryKey: data.account.recoveryKey?.exists ?? false, hasSecondaryVerifiedEmail: data.account.emails.length > 1 && data.account.emails[1].verified, - totpActive: data.account.totp.exists && data.account.totp.verified, + totpActive: (data.account.totp?.exists && data.account.totp?.verified) ?? false, }); } }, [ @@ -394,6 +395,10 @@ const SettingsRoutes = ({ const location = useLocation(); const isSync = integration != null ? integration.isSync() : false; + // Check localStorage directly — prop is async, localStorage is sync after storeAccountData() + const { data: localSignedInData } = useLocalSignedInQueryState(); + const effectiveIsSignedIn = isSignedIn || localSignedInData?.isSignedIn; + // If the user is not signed in, they cannot access settings! Direct them accordingly // Deferring navigation to an effect prevents React from detecting a navigation // during render, which can trigger "A component suspended while responding to @@ -401,16 +406,16 @@ const SettingsRoutes = ({ // hardNavigate here ensures the update occurs after render. useEffect(() => { - if (!isSignedIn && !isSync) { + if (!effectiveIsSignedIn && !isSync) { // For regular RP / web logins, maybe the session token expired. // In this case we just send them to the root. const params = new URLSearchParams(location.search); params.set('redirect_to', location.pathname); hardNavigate(`/?${params.toString()}`); } - }, [isSignedIn, isSync, location.pathname, location.search]); + }, [effectiveIsSignedIn, isSync, location.pathname, location.search]); - if (!isSignedIn) { + if (!effectiveIsSignedIn) { if (isSync) { // For sync this means we somehow dropped the sign out message, which is // a known issue in android. In this case, our best option is to ask the @@ -422,14 +427,16 @@ const SettingsRoutes = ({ const settingsContext = initializeSettingsContext(); return ( - - - - - + + + + + + + ); }; diff --git a/packages/fxa-settings/src/components/App/interfaces.ts b/packages/fxa-settings/src/components/App/interfaces.ts index 36228d87421..2a73037417c 100644 --- a/packages/fxa-settings/src/components/App/interfaces.ts +++ b/packages/fxa-settings/src/components/App/interfaces.ts @@ -2,12 +2,17 @@ * 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 { AccountData } from '../../models'; +import { Email } from '../../models'; +import { AccountTotp } from '../../lib/interfaces'; -export type MetricsData = Pick< - AccountData, - 'uid' | 'recoveryKey' | 'metricsEnabled' | 'primaryEmail' | 'emails' | 'totp' ->; +export interface MetricsData { + uid: string | null; + recoveryKey: { exists: boolean; estimatedSyncDeviceCount?: number } | null; + metricsEnabled: boolean; + primaryEmail: Email | null; + emails: Email[]; + totp: AccountTotp | null; +} export type MetricsDataResult = { account: MetricsData }; diff --git a/packages/fxa-settings/src/components/LegalWithMarkdown/index.tsx b/packages/fxa-settings/src/components/LegalWithMarkdown/index.tsx index c66f8f4bacd..492849ad68d 100644 --- a/packages/fxa-settings/src/components/LegalWithMarkdown/index.tsx +++ b/packages/fxa-settings/src/components/LegalWithMarkdown/index.tsx @@ -2,7 +2,7 @@ * 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, { useContext, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import AppLayout from '../AppLayout'; import { navigate } from '@reach/router'; import { FtlMsg } from 'fxa-react/lib/utils'; @@ -12,7 +12,7 @@ import MarkdownLegal from '../MarkdownLegal'; import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; import { REACT_ENTRYPOINT } from '../../constants'; import { fetchLegalMd, LegalDocFile } from '../../lib/file-utils-legal'; -import { AppContext, useFtlMsgResolver } from '../../models'; +import { useFtlMsgResolver } from '../../models'; import { searchParams } from '../../lib/utilities'; import Banner from '../Banner'; @@ -41,7 +41,6 @@ const LegalWithMarkdown = ({ usePageViewEvent(viewName, REACT_ENTRYPOINT); const [markdown, setMarkdown] = useState(); const [error, setError] = useState(); - const { apolloClient } = useContext(AppContext); const ftlMsgResolver = useFtlMsgResolver(); useEffect(() => { @@ -51,7 +50,7 @@ const LegalWithMarkdown = ({ if (fetchLegalDoc != null) { return fetchLegalDoc(locale, legalDocFile); } - return fetchLegalMd(apolloClient, locale, legalDocFile); + return fetchLegalMd(locale, legalDocFile); } const { markdown: fetchedMarkdown, error } = await fetchLegal( @@ -71,14 +70,13 @@ const LegalWithMarkdown = ({ return () => { isMounted = false; }; - }, [locale, legalDocFile, apolloClient, fetchLegalDoc]); + }, [locale, legalDocFile, fetchLegalDoc]); const buttonHandler = () => { logViewEvent(`flow.${viewName}`, 'back', REACT_ENTRYPOINT); - navigate( - (searchParams(window.location.search) as any).contentRedirect ? -2 : -1 - ); + const params = searchParams(window.location.search) as Record; + navigate(params.contentRedirect ? -2 : -1); }; return ( diff --git a/packages/fxa-settings/src/components/Settings/AlertBar/index.test.tsx b/packages/fxa-settings/src/components/Settings/AlertBar/index.test.tsx index a575def5f15..aa48ec68559 100644 --- a/packages/fxa-settings/src/components/Settings/AlertBar/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/AlertBar/index.test.tsx @@ -7,8 +7,8 @@ import { screen } from '@testing-library/react'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import AlertBar from '.'; -jest.mock('@apollo/client', () => ({ - ...jest.requireActual('@apollo/client'), +jest.mock('../../../lib/reactive-var', () => ({ + ...jest.requireActual('../../../lib/reactive-var'), useReactiveVar: (x: Function) => x(), })); diff --git a/packages/fxa-settings/src/components/Settings/AlertBar/index.tsx b/packages/fxa-settings/src/components/Settings/AlertBar/index.tsx index 5267b974990..8a80dcb6a34 100644 --- a/packages/fxa-settings/src/components/Settings/AlertBar/index.tsx +++ b/packages/fxa-settings/src/components/Settings/AlertBar/index.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useEscKeydownEffect, useChangeFocusEffect } from '../../../lib/hooks'; import { ReactComponent as CloseIcon } from '@fxa/shared/assets/images/close.svg'; import { alertContent, alertType, alertVisible } from '../../../models'; -import { useReactiveVar } from '@apollo/client'; +import { useReactiveVar } from '../../../lib/reactive-var'; import { useClickOutsideEffect } from 'fxa-react/lib/hooks'; import { useLocalization } from '@fluent/react'; import classNames from 'classnames'; @@ -25,6 +25,8 @@ import classNames from 'classnames'; export const AlertBar = () => { const { l10n } = useLocalization(); const visible = useReactiveVar(alertVisible); + const content = useReactiveVar(alertContent); + const type = useReactiveVar(alertType); const insideRef = useClickOutsideEffect(() => { // TODO: cleanup Portal component and references, FXA-2463 // We don't want to automatically close the alert bar if a modal @@ -91,10 +93,10 @@ export const AlertBar = () => { className={classNames( 'max-w-full desktop:max-w-2xl w-full desktop:min-w-sm flex shadow-md rounded-sm text-sm font-medium text-grey-700 border border-transparent', { - 'bg-red-100 error': alertType() === 'error', - 'bg-blue-50 info': alertType() === 'info', - 'bg-green-200 success': alertType() === 'success', - 'bg-orange-50 warning': alertType() === 'warning', + 'bg-red-100 error': type === 'error', + 'bg-blue-50 info': type === 'info', + 'bg-green-200 success': type === 'success', + 'bg-orange-50 warning': type === 'warning', } )} > @@ -106,18 +108,16 @@ export const AlertBar = () => { : 'text-center' )} > - {alertContent()} + {content}

diff --git a/packages/fxa-settings/src/components/Settings/Page2faReplaceBackupCodes/index.tsx b/packages/fxa-settings/src/components/Settings/Page2faReplaceBackupCodes/index.tsx index b712bad8537..fd32b548321 100644 --- a/packages/fxa-settings/src/components/Settings/Page2faReplaceBackupCodes/index.tsx +++ b/packages/fxa-settings/src/components/Settings/Page2faReplaceBackupCodes/index.tsx @@ -11,7 +11,6 @@ import { useAlertBar, useConfig, useFtlMsgResolver, - useSession, } from '../../../models'; import { GleanClickEventType2FA, MfaReason } from '../../../lib/types'; import GleanMetrics from '../../../lib/glean'; @@ -34,7 +33,6 @@ export const MfaGuardPage2faReplaceBackupCodes = ( export const Page2faReplaceBackupCodes = (_: RouteComponentProps) => { const alertBar = useAlertBar(); const navigateWithQuery = useNavigateWithQuery(); - const session = useSession(); const account = useAccount(); const config = useConfig(); const ftlMsgResolver = useFtlMsgResolver(); @@ -155,8 +153,10 @@ export const Page2faReplaceBackupCodes = (_: RouteComponentProps) => { }; useEffect(() => { - session.verified && newBackupCodes.length < 1 && createNewBackupCodes(); - }, [session, newBackupCodes, createNewBackupCodes]); + if (newBackupCodes.length < 1) { + createNewBackupCodes(); + } + }, [newBackupCodes, createNewBackupCodes]); const localizedPageTitle = ftlMsgResolver.getMsg( 'tfa-backup-codes-page-title', diff --git a/packages/fxa-settings/src/components/Settings/PageChangePassword/index.test.tsx b/packages/fxa-settings/src/components/Settings/PageChangePassword/index.test.tsx index 8aff7383b70..fe7af371e19 100644 --- a/packages/fxa-settings/src/components/Settings/PageChangePassword/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/PageChangePassword/index.test.tsx @@ -61,6 +61,12 @@ const mockAuthClient = { kA: 'kA-key', kB: 'kB-key', }), + sessionStatus: jest.fn().mockResolvedValue({ + state: 'verified', + details: { + sessionVerified: true, + }, + }), } as any; // Use 'as any' to avoid TypeScript strict typing for mock // Mock the cache module to provide session token and JWT cache diff --git a/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.test.tsx b/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.test.tsx index ea6da5b2fed..1c317626da7 100644 --- a/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.test.tsx @@ -22,18 +22,13 @@ import { typeByTestIdFn } from '../../../lib/test-utils'; import { Account, AppContext } from '../../../models'; import { MOCK_EMAIL } from '../../../pages/mocks'; import GleanMetrics from '../../../lib/glean'; +import { discardSessionToken, clearSignedInAccountUid } from '../../../lib/cache'; -const mockApolloClearStore = jest.fn().mockResolvedValue(undefined); - -jest.mock('@apollo/client', () => { - const actual = jest.requireActual('@apollo/client'); - return { - ...actual, - useApolloClient: () => ({ - clearStore: mockApolloClearStore, - }), - }; -}); +jest.mock('../../../lib/cache', () => ({ + ...jest.requireActual('../../../lib/cache'), + discardSessionToken: jest.fn(), + clearSignedInAccountUid: jest.fn(), +})); jest.mock('../../../lib/metrics', () => ({ logViewEvent: jest.fn(), @@ -188,7 +183,8 @@ describe('PageDeleteAccount', () => { await waitFor(() => { expect(mockDestroy).toHaveBeenCalled(); }); - expect(mockApolloClearStore).toHaveBeenCalled(); + expect(discardSessionToken).toHaveBeenCalled(); + expect(clearSignedInAccountUid).toHaveBeenCalled(); expect(window.location.pathname).toContainEqual('/'); }); @@ -208,7 +204,8 @@ describe('PageDeleteAccount', () => { await waitFor(() => { expect(mockDestroy).toHaveBeenCalled(); }); - expect(mockApolloClearStore).toHaveBeenCalled(); + expect(discardSessionToken).toHaveBeenCalled(); + expect(clearSignedInAccountUid).toHaveBeenCalled(); expect(window.location.pathname).toContainEqual('/'); }); diff --git a/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.tsx b/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.tsx index 6a52dd79eea..5b8ba58c85b 100644 --- a/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.tsx +++ b/packages/fxa-settings/src/components/Settings/PageDeleteAccount/index.tsx @@ -19,7 +19,7 @@ import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import { getLocalizedErrorMessage } from '../../../lib/error-utils'; import GleanMetrics from '../../../lib/glean'; import { useFtlMsgResolver } from '../../../models/hooks'; -import { useApolloClient } from '@apollo/client'; +import { clearSignedInAccountUid, discardSessionToken, setSigningOut } from '../../../lib/cache'; type FormData = { password: string; @@ -103,7 +103,6 @@ export const PageDeleteAccount = (_: RouteComponentProps) => { const goHome = useCallback(() => window.history.back(), []); const account = useAccount(); - const apolloClient = useApolloClient(); useEffect(() => { GleanMetrics.deleteAccount.view(); @@ -138,7 +137,9 @@ export const PageDeleteAccount = (_: RouteComponentProps) => { 'flow.settings.account-delete', 'confirm-password.success' ); - await apolloClient.clearStore(); + setSigningOut(true); + discardSessionToken(); + clearSignedInAccountUid(); navigateWithQuery('/', { state: { @@ -163,7 +164,6 @@ export const PageDeleteAccount = (_: RouteComponentProps) => { alertBar, ftlMsgResolver, navigateWithQuery, - apolloClient, ] ); diff --git a/packages/fxa-settings/src/components/Settings/PageMfaGuardWithGqlTest/index.tsx b/packages/fxa-settings/src/components/Settings/PageMfaGuardWithGqlTest/index.tsx deleted file mode 100644 index 87a1777abb7..00000000000 --- a/packages/fxa-settings/src/components/Settings/PageMfaGuardWithGqlTest/index.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * 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 { useState, useEffect, useSyncExternalStore } from 'react'; -import { - JwtNotFoundError, - JwtTokenCache, - sessionToken as getSessionToken, -} from '../../../lib/cache'; - -import { MfaGuard, useMfaErrorHandler } from '../MfaGuard'; -import { RouteComponentProps } from '@reach/router'; -import { ApolloError, gql, useMutation } from '@apollo/client'; -import { MfaReason } from '../../../lib/types'; - -export const PageMfaGuardTestWithGql = (props: RouteComponentProps) => { - return ( - - - - ); -}; - -export default PageMfaGuardTestWithGql; - -const MFA_TEST_MUTATION = gql` - mutation MfaTest($input: MfaTestInput!) { - mfaTest(input: $input) { - status - } - } -`; - -const TestWithGql = (_: RouteComponentProps) => { - const handleMfaError = useMfaErrorHandler(); - const jwtCache = useSyncExternalStore( - JwtTokenCache.subscribe, - JwtTokenCache.getSnapshot - ); - const [status, setStatus] = useState(''); - const [refresh, setRefresh] = useState(0); - - const [mfaTest] = useMutation(MFA_TEST_MUTATION, { - onError() { - // no-op - }, - }); - - const sessionToken = getSessionToken(); - if (!sessionToken) { - throw new Error('Invalid state. Session token missing!@'); - } - - // Each page will have a unique scope, possibly shared with other pages - const scope = 'test'; - const jwtKey = `${sessionToken}-${scope}`; - - // Fire off the request to test if the JWT worked or not - // If this throws an exception, we should get a 110 errno back - // and the guard's modal should pop up again - useEffect(() => { - (async () => { - const jwt = JwtTokenCache.getToken(sessionToken, scope); - - const result = await mfaTest({ - variables: { - input: { - jwt: jwt, - }, - }, - }); - - if (result.data?.mfaTest) { - setStatus( - result.data?.mfaTest.status === 'success' ? 'valid' : 'invalid' - ); - } else if (result.errors instanceof ApolloError) { - // extensions holds the auth server errno and code - handleMfaError(result.errors?.cause?.extensions); - } - })(); - }, [jwtCache, mfaTest, handleMfaError, sessionToken]); - - // Wrap the page's content with an MfaGuard to protect it from access without - // a JWT that has a scope of "test" - return ( - <> - JWT Status Check -
-
-

- Your JWT status is:

{status}
-

-
-

- Your JWT is held in the cache under: -

{jwtKey}
-

-
-

- Your JWT value is: -

{jwtCache[jwtKey]}
-

-
-

- Page Refreshes

{refresh}
-

- -
-
- - -
-
- - -
-
- - -
-
- - - ); -}; diff --git a/packages/fxa-settings/src/components/Settings/PageSecondaryEmailAdd/index.test.tsx b/packages/fxa-settings/src/components/Settings/PageSecondaryEmailAdd/index.test.tsx index e11d2d6ff4c..ab6ff49a6e8 100644 --- a/packages/fxa-settings/src/components/Settings/PageSecondaryEmailAdd/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/PageSecondaryEmailAdd/index.test.tsx @@ -215,6 +215,13 @@ describe('PageSecondaryEmailAdd', () => { mockAuthClient.mfaOtpVerify = jest .fn() .mockResolvedValueOnce({ accessToken: mockJwt }); + // VerifiedSessionGuard calls sessionStatus, so we need to mock it + mockAuthClient.sessionStatus = jest.fn().mockResolvedValue({ + state: 'verified', + details: { + sessionVerified: true, + }, + }); }; const resetJwtCache = () => { diff --git a/packages/fxa-settings/src/components/Settings/UnitRowRecoveryKey/index.test.tsx b/packages/fxa-settings/src/components/Settings/UnitRowRecoveryKey/index.test.tsx index d440ac9b946..519c6b4dc7e 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowRecoveryKey/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowRecoveryKey/index.test.tsx @@ -20,6 +20,12 @@ jest.mock('../../../lib/cache', () => ({ ...jest.requireActual('../../../lib/cache'), sessionToken: jest.fn(), currentAccount: jest.fn(), + JwtTokenCache: { + hasToken: jest.fn(), + getToken: jest.fn(), + getSnapshot: jest.fn().mockReturnValue({}), + subscribe: jest.fn().mockReturnValue(() => {}), + }, })); const sessionToken = 'session-123'; @@ -40,7 +46,15 @@ const accountWithoutPassword = { recoveryKey: { exists: false }, } as unknown as Account; -const authClient = {} as unknown as AuthClient; +const authClient = { + sessionStatus: jest.fn().mockResolvedValue({ + state: 'verified', + details: { + sessionVerified: true, + }, + }), + mfaRequestOtp: jest.fn().mockResolvedValue({ code: 200, errno: 0 }), +} as unknown as AuthClient; const renderWithContext = ( account: Partial, diff --git a/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.test.tsx b/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.test.tsx index c82f6c2b3e9..90c8e1a1c3e 100644 --- a/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/UnitRowTwoStepAuth/index.test.tsx @@ -20,6 +20,12 @@ jest.mock('../../../models', () => ({ ...jest.requireActual('../../../models'), useAuthClient: () => ({ mfaRequestOtp: jest.fn(), + sessionStatus: jest.fn().mockResolvedValue({ + state: 'verified', + details: { + sessionVerified: true, + }, + }), }), })); diff --git a/packages/fxa-settings/src/components/Settings/VerifiedSessionGuard/index.test.tsx b/packages/fxa-settings/src/components/Settings/VerifiedSessionGuard/index.test.tsx index 40097a2a4b6..7d67dd002e5 100644 --- a/packages/fxa-settings/src/components/Settings/VerifiedSessionGuard/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/VerifiedSessionGuard/index.test.tsx @@ -33,6 +33,7 @@ it('renders the content when verified', async () => { authClient: { sessionStatus: () => { return { + state: 'verified', details: { sessionVerified: true, }, @@ -51,6 +52,42 @@ it('renders the content when verified', async () => { expect(screen.getByTestId('children')).toBeInTheDocument(); }); +it('calls onError when session status check is rate limited', async () => { + const account = { + primaryEmail: { + email: 'smcarthur@mozilla.com', + }, + } as unknown as Account; + const onDismiss = jest.fn(); + const onError = jest.fn(); + + await act( + async () => + await renderWithRouter( + { + const err = Object.assign(new Error("You've tried too many times. Try again later."), { errno: 114 }); + throw err; + }, + } as unknown as AuthClient, + })} + > + +
Content
+
+
+ ) + ); + + expect(onError).toHaveBeenCalled(); + expect(screen.queryByTestId('children')).not.toBeInTheDocument(); + expect(screen.queryByTestId('modal-verify-session')).not.toBeInTheDocument(); +}); + it('renders the guard when unverified', async () => { const onDismiss = jest.fn(); const onError = jest.fn(); @@ -70,6 +107,7 @@ it('renders the guard when unverified', async () => { authClient: { sessionStatus: () => { return { + state: 'unverified', details: { sessionVerified: false, }, diff --git a/packages/fxa-settings/src/components/Settings/VerifiedSessionGuard/index.tsx b/packages/fxa-settings/src/components/Settings/VerifiedSessionGuard/index.tsx index 1fdcb653708..3679d215517 100644 --- a/packages/fxa-settings/src/components/Settings/VerifiedSessionGuard/index.tsx +++ b/packages/fxa-settings/src/components/Settings/VerifiedSessionGuard/index.tsx @@ -2,9 +2,9 @@ * 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 from 'react'; -import { ApolloError } from '@apollo/client'; -import { useSession } from '../../../models'; +import { useState, useCallback, useEffect, ReactNode } from 'react'; +import { useSession, useAuthClient } from '../../../models'; +import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import ModalVerifySession from '../ModalVerifySession'; export const VerifiedSessionGuard = ({ @@ -13,15 +13,44 @@ export const VerifiedSessionGuard = ({ children, }: { onDismiss: () => void; - onError: (error: ApolloError) => void; - children?: React.ReactNode; + onError: (error: Error) => void; + children?: ReactNode; }) => { const session = useSession(); + const authClient = useAuthClient(); + const [isVerified, setIsVerified] = useState(null); - return session.verified ? ( + // Check session status on mount to avoid flash + useEffect(() => { + const checkStatus = async () => { + try { + const status = await authClient.sessionStatus(session.token); + setIsVerified(status.state === 'verified'); + } catch (error: unknown) { + const err = error as { errno?: number }; + if (err.errno === AuthUiErrors.THROTTLED.errno) { + onError(error as unknown as Error); + return; + } + setIsVerified(false); + } + }; + checkStatus(); + }, [authClient, session.token, onError]); + + const onCompleted = useCallback(() => { + setIsVerified(true); + }, []); + + // Show nothing while checking status + if (isVerified === null) { + return null; + } + + return isVerified ? ( <>{children} ) : ( - + ); }; diff --git a/packages/fxa-settings/src/components/Settings/index.test.tsx b/packages/fxa-settings/src/components/Settings/index.test.tsx index ce86fcd36ef..06c001fc425 100644 --- a/packages/fxa-settings/src/components/Settings/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/index.test.tsx @@ -5,7 +5,8 @@ import React, { ReactNode } from 'react'; import { History } from '@reach/router'; import { waitFor } from '@testing-library/react'; -import { Account, AppContext, useInitialSettingsState } from '../../models'; +import { Account, AppContext } from '../../models'; +import { useAccountState } from '../../models/contexts/AccountStateContext'; import { mockAppContext, MOCK_ACCOUNT, @@ -17,25 +18,49 @@ import { SETTINGS_PATH } from '../../constants'; import AppLocalizationProvider from 'fxa-react/lib/AppLocalizationProvider'; import { Subject } from './mocks'; -jest.mock('@apollo/client', () => { - const actual = jest.requireActual('@apollo/client'); - return { - ...actual, - useApolloClient: () => ({ - clearStore: jest.fn().mockResolvedValue(undefined), - }), - }; -}); - const mockSessionStatus = jest.fn(); +const mockAccountState = { + isLoading: false, + error: null, + uid: 'mock-uid', + email: 'johndoe@example.com', + metricsEnabled: true, + verified: true, + primaryEmail: { email: 'johndoe@example.com', isPrimary: true, verified: true }, + displayName: null, + avatar: null, + emails: [], + totp: null, + backupCodes: null, + recoveryKey: null, + recoveryPhone: null, + attachedClients: [], + linkedAccounts: [], + subscriptions: [], + securityEvents: [], + accountCreated: null, + passwordCreated: null, + hasPassword: true, + loadingFields: new Set(), + setAccountData: jest.fn(), + updateField: jest.fn(), + setLoading: jest.fn(), + setFieldLoading: jest.fn(), + setError: jest.fn(), + clearAccount: jest.fn(), +}; jest.mock('../../models', () => ({ ...jest.requireActual('../../models'), - useInitialSettingsState: jest.fn(), useAuthClient: jest.fn(() => ({ sessionStatus: mockSessionStatus, })), })); +jest.mock('../../models/contexts/AccountStateContext', () => ({ + ...jest.requireActual('../../models/contexts/AccountStateContext'), + useAccountState: jest.fn(), +})); + jest.mock('../../lib/totp-utils', () => { const mockBackupCodes = ['0123456789']; return { @@ -88,7 +113,8 @@ describe('Settings App', () => { beforeEach(() => { jest.clearAllMocks(); jest.spyOn(console, 'error').mockImplementation(() => {}); - (useInitialSettingsState as jest.Mock).mockReturnValue({ loading: false }); + // Reset account state mock to default values + (useAccountState as jest.Mock).mockReturnValue({ ...mockAccountState, isLoading: false, error: null }); mockNavigate.mockReset(); mockSessionStatus.mockResolvedValue({ details: { @@ -105,8 +131,9 @@ describe('Settings App', () => { }); it('renders `LoadingSpinner` component when loading initial state is true', () => { - (useInitialSettingsState as jest.Mock).mockReturnValueOnce({ - loading: true, + (useAccountState as jest.Mock).mockReturnValueOnce({ + ...mockAccountState, + isLoading: true, }); const { getByLabelText } = renderWithRouter( @@ -118,19 +145,24 @@ describe('Settings App', () => { }); it('renders `AppErrorDialog` component when settings query errors', async () => { - (useInitialSettingsState as jest.Mock).mockReturnValue({ - error: { message: 'Error' }, - }); - const { getByRole } = renderWithRouter( + (useAccountState as jest.Mock).mockImplementation(() => ({ + ...mockAccountState, + isLoading: false, + error: new Error('Error'), + })); + const { getByTestId } = renderWithRouter( ); + // Wait for sessionStatus to be called - the component needs this to get past the loading check + await waitFor(() => { + expect(mockSessionStatus).toHaveBeenCalled(); + }); + await waitFor(() => { - expect(getByRole('heading', { level: 2 })).toHaveTextContent( - 'General application error' - ); + expect(getByTestId('error-loading-app')).toBeInTheDocument(); }); }); @@ -383,11 +415,6 @@ describe('Settings App', () => { route: '/mfa_guard/test/auth_client', hasPassword: false, }, - { - pageName: 'PageMfaGuardTestWithGql', - route: '/mfa_guard/test/gql', - hasPassword: false, - }, { pageName: 'Page2faChange', route: '/two_step_authentication/change', diff --git a/packages/fxa-settings/src/components/Settings/index.tsx b/packages/fxa-settings/src/components/Settings/index.tsx index 1d6fade1c33..3bda1139967 100644 --- a/packages/fxa-settings/src/components/Settings/index.tsx +++ b/packages/fxa-settings/src/components/Settings/index.tsx @@ -10,9 +10,9 @@ import AppErrorDialog from 'fxa-react/components/AppErrorDialog'; import { useAccount, useAuthClient, - useInitialSettingsState, useSession, } from '../../models'; +import { useAccountData, InvalidTokenError } from '../../lib/hooks/useAccountData'; import { Redirect, Router, @@ -44,7 +44,6 @@ import { SettingsIntegration } from './interfaces'; import { useNavigateWithQuery } from '../../lib/hooks/useNavigateWithQuery'; import PageMfaGuardTestWithAuthClient from './PageMfaGuardTest'; -import PageMfaGuardTestWithGql from './PageMfaGuardWithGqlTest'; export const Settings = ({ integration, @@ -58,64 +57,44 @@ export const Settings = ({ const [sessionVerificationMeetsAAL, setSessionVerificationMeetsAAL] = useState(); + const { isLoading: loading, error } = useAccountData({ authClient }); + useEffect(() => { /** - * If we have an active session, we need to handle the possibility - * that it will reflect the session for the current tab. It's - * important to note that there is also a cache in local storage, and - * as such it is shared between all tabs. So, in the event a user has - * account A signed in on tab 1, and account B signed in on tab 2, local - * storage will reflect the account uid of whichever account was the last - * to be sign in. + * Handle multi-tab account state synchronization. + * + * Account state is stored in localStorage and shared between all tabs. + * When a user has account A signed in on tab 1, and account B signed in + * on tab 2, localStorage reflects whichever account was last signed in. * - * By noting the window focus, we actively swap the current account uid - * in local storage so that it matches the apollo cache's account uid, - * which is held in page memory, thereby fixing this discrepancy. + * On window focus, we sync the current account in localStorage to match + * the in-memory account state for this tab. * - * Having multiple things cached in multiple places is never great, so we - * have a ticket in the backlog for cleaning this up, and avoiding this - * hack in the future. See FXA-9875 for more info. + * See FXA-9875 for potential cleanup of this multi-tab state handling. */ function handleWindowFocus() { - // Try to retrieve the active account uid from the apollo cache. - const accountUidFromApolloCache = (() => { + const accountUidFromContext = (() => { try { return account.uid; } catch {} return undefined; })(); - // During normal usage, we should not see this. However, if this happens many - // functions on the page would be broken, because it indicates the apollo - // for the active account was cleared. In this case, navigate back to the - // signin page - if (accountUidFromApolloCache === undefined) { - console.warn('Could not access account.uid from apollo cache!'); + if (accountUidFromContext === undefined) { + console.warn('Could not access account.uid from context!'); navigateWithQuery('/'); return; } - // If the current account in local storage matches the account in the - // apollo cache, the state is syncrhonized and no action is required. - if (currentAccount()?.uid === accountUidFromApolloCache) { + if (currentAccount()?.uid === accountUidFromContext) { return; } - // If there is not a match, and the state exists in local storage, swap - // the active account, so apollo cache and localstorage are in sync. - if (hasAccount(accountUidFromApolloCache)) { - setCurrentAccount(accountUidFromApolloCache); + if (hasAccount(accountUidFromContext)) { + setCurrentAccount(accountUidFromContext); return; } - // We have hit an unexpected state. The account UID reflected by the apollo - // cache does not match any known account state in local storage. - // This is could occur if: - // - The same account was signed out on another tab - // - A user localstorage was manually cleared. - // - // Either way, we cannot reliable sync up apollo cache and localstorage, so - // we will direct back to the login page. console.warn('Could not locate current account in local storage'); navigateWithQuery('/'); } @@ -123,7 +102,6 @@ export const Settings = ({ return () => window.removeEventListener('focus', handleWindowFocus); }, [account, navigateWithQuery, session]); - const { loading, error } = useInitialSettingsState(); const { enabled: gleanEnabled } = GleanMetrics.useGlean(); useEffect(() => { @@ -153,6 +131,11 @@ export const Settings = ({ // This error check includes a network error if (error) { + // If the session token is invalid (destroyed/expired), redirect to signin + if (error instanceof InvalidTokenError) { + navigateWithQuery('/signin'); + return ; + } Sentry.captureException(error, { tags: { source: 'settings' } }); GleanMetrics.error.view({ event: { reason: error.message } }); return ; @@ -228,7 +211,6 @@ export const Settings = ({ - diff --git a/packages/fxa-settings/src/index.tsx b/packages/fxa-settings/src/index.tsx index 480f6cd322e..81423abf1d2 100644 --- a/packages/fxa-settings/src/index.tsx +++ b/packages/fxa-settings/src/index.tsx @@ -11,8 +11,6 @@ import { NimbusProvider } from './models/contexts/NimbusContext'; import config, { readConfigMeta } from './lib/config'; import { searchParams } from './lib/utilities'; import { AppContext, initializeAppContext } from './models'; -import { ApolloProvider } from '@apollo/client'; -import { createApolloClient } from './lib/gql'; import Storage from './lib/storage'; import './styles/tailwind.out.css'; import CookiesDisabled from './pages/CookiesDisabled'; @@ -52,7 +50,7 @@ try { return document.head.querySelector(name); }); - // Must be configured before apollo is created. Otherwise baggage and sentry-trace headers won't be added + // Must be configured early. Otherwise baggage and sentry-trace headers won't be added sentryMetrics.configure({ release: config.version, sentry: { @@ -70,7 +68,6 @@ try { }, }); - const apolloClient = createApolloClient(config.servers.gql.url); const appContext = initializeAppContext(); const View = Storage.isLocalStorageEnabled(window) @@ -86,9 +83,7 @@ try { - - - + diff --git a/packages/fxa-settings/src/lib/account-storage.ts b/packages/fxa-settings/src/lib/account-storage.ts index 28e9d781012..eb2fb2b3e86 100644 --- a/packages/fxa-settings/src/lib/account-storage.ts +++ b/packages/fxa-settings/src/lib/account-storage.ts @@ -128,9 +128,9 @@ function getLegacyExtendedStateKey(uid: string): string { return `accountState_${uid}`; } -function dispatchStorageEvent(): void { +export function dispatchStorageEvent(key = 'accounts'): void { window.dispatchEvent( - new CustomEvent('localStorageChange', { detail: { key: 'accounts' } }) + new CustomEvent('localStorageChange', { detail: { key } }) ); } @@ -393,9 +393,5 @@ export function removeAccount(uid?: string): void { export function setCurrentAccountUid(uid: string): void { storage().set('currentAccountUid', uid); - window.dispatchEvent( - new CustomEvent('localStorageChange', { - detail: { key: 'currentAccountUid' }, - }) - ); + dispatchStorageEvent('currentAccountUid'); } diff --git a/packages/fxa-settings/src/lib/gql-key-stretch-upgrade.ts b/packages/fxa-settings/src/lib/auth-key-stretch-upgrade.ts similarity index 61% rename from packages/fxa-settings/src/lib/gql-key-stretch-upgrade.ts rename to packages/fxa-settings/src/lib/auth-key-stretch-upgrade.ts index 20c6385a969..296652e7d92 100644 --- a/packages/fxa-settings/src/lib/gql-key-stretch-upgrade.ts +++ b/packages/fxa-settings/src/lib/auth-key-stretch-upgrade.ts @@ -2,14 +2,7 @@ * 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 { MutationFunction } from '@apollo/client'; -import { - CredentialStatus, - CredentialStatusResponse, - GetAccountKeysResponse, - PasswordChangeFinishResponse, - PasswordChangeStartResponse, -} from '../pages/Signin/interfaces'; +import AuthClient, { CredentialStatus } from 'fxa-auth-client/browser'; import * as Sentry from '@sentry/browser'; import { getCredentials, @@ -33,27 +26,24 @@ export type V2Credentials = V1Credentials & { }; /** - * Convenience function for attempting an upgrade and catching any errors that might happen. - * Note that this will manually report GQL errors to Sentry. + * Attempt to finalize a v2 key-stretching upgrade. + * This is a best-effort operation - failures are logged to Sentry but don't block sign-in. + * + * @param sessionId - The session token ID + * @param sensitiveDataClient - Client containing the upgrade credentials + * @param stage - Description of the current auth flow stage (for Sentry context) + * @param authClient - Auth client instance + * @returns true if upgrade succeeded, false otherwise */ export async function tryFinalizeUpgrade( sessionId: string, sensitiveDataClient: SensitiveDataClient, stage: string, - gqlCredentialStatus: MutationFunction, - gqlGetWrappedKeys: MutationFunction, - gqlPasswordChangeStart: MutationFunction, - gqlPasswordChangeFinish: MutationFunction + authClient: AuthClient ) { try { if (sensitiveDataClient.KeyStretchUpgradeData) { - const upgradeClient = new GqlKeyStretchUpgrade( - stage, - gqlCredentialStatus, - gqlGetWrappedKeys, - gqlPasswordChangeStart, - gqlPasswordChangeFinish - ); + const upgradeClient = new AuthKeyStretchUpgrade(stage, authClient); await upgradeClient.upgrade( sensitiveDataClient.KeyStretchUpgradeData.email, @@ -65,27 +55,36 @@ export async function tryFinalizeUpgrade( } } catch (error) { // NO-OP Don't let a key stretching upgrade issue prevent sign in. - // Note that the upgradeClient reports errors to sentry. } finally { - // Clear out the state. No reason to keep trying this... - // Note that the upgradeClient will report issues to Sentry. sensitiveDataClient.KeyStretchUpgradeData = undefined; } return false; } /** - * Handles upgrade process for key stretching + * Handles the v1 -> v2 key stretching upgrade process. + * + * V2 key stretching improves security by adding an additional key derivation step. + * The upgrade is performed transparently during sign-in when: + * 1. The account is still using v1 credentials + * 2. V2 key stretching is enabled for the user + * + * The upgrade flow: + * 1. Check if upgrade is needed via getCredentialStatusV2 + * 2. Start password change with v1 credentials + * 3. Get wrapped keys using keyFetchToken + * 4. Finish password change with both v1 and v2 credentials */ -export class GqlKeyStretchUpgrade { +export class AuthKeyStretchUpgrade { constructor( private readonly stage: string, - private readonly gqlCredentialStatus: MutationFunction, - private readonly gqlGetWrappedKeys: MutationFunction, - private readonly gqlPasswordChangeStart: MutationFunction, - private readonly gqlPasswordChangeFinish: MutationFunction + private readonly authClient: AuthClient ) {} + /** + * Derive credentials from email and password. + * If v2 is enabled, also derives v2 credentials and checks upgrade status. + */ async getCredentials( email: string, password: string, @@ -115,13 +114,19 @@ export class GqlKeyStretchUpgrade { }; } + /** + * Perform the v1 -> v2 key stretching upgrade. + * This is a multi-step process that requires valid session and account verification. + * + * @returns true if upgrade succeeded, false if any step failed + */ async upgrade( email: string, v1Credentials: V1Credentials, v2Credentials: V2Credentials, sessionToken: string ): Promise { - let result1 = await this.startUpgrade(email, v1Credentials, sessionToken); + const result1 = await this.startUpgrade(email, v1Credentials, sessionToken); if (result1?.keyFetchToken && result1?.passwordChangeToken) { const result2 = await this.getWrappedKeys(result1.keyFetchToken); @@ -143,12 +148,12 @@ export class GqlKeyStretchUpgrade { email: string ): Promise { try { - const result = await this.gqlCredentialStatus({ - variables: { - input: email, - }, - }); - return result.data?.credentialStatus; + const result = await this.authClient.getCredentialStatusV2(email); + return { + upgradeNeeded: result.upgradeNeeded, + currentVersion: result.currentVersion, + clientSalt: result.clientSalt, + }; } catch (error) { Sentry.captureMessage( `Failure to finish v2 key-stretching upgrade. Could not get credential status during ${this.stage}`, @@ -168,37 +173,27 @@ export class GqlKeyStretchUpgrade { sessionToken: string ) { try { - const response = await this.gqlPasswordChangeStart({ - variables: { - input: { - email: email, - oldAuthPW: v1Credentials.authPW, - sessionToken, - }, - }, - }); - const passwordChangeToken = - response.data?.passwordChangeStart?.passwordChangeToken || ''; - const keyFetchToken = - response.data?.passwordChangeStart?.keyFetchToken || ''; + const response = await this.authClient.passwordChangeStartWithAuthPW( + email, + v1Credentials.authPW, + sessionToken + ); return { - keyFetchToken, - passwordChangeToken, + keyFetchToken: response.keyFetchToken || '', + passwordChangeToken: response.passwordChangeToken || '', }; } catch (error) { const errno = getHandledError(error).error.errno; + // These are expected conditions where upgrade should be deferred, not errors if (errno === ERRNO.ACCOUNT_UNVERIFIED) { - // Session not verified. Trying again later. - console.info('Account not verified. Try upgrade later.'); + console.info('Key stretch upgrade deferred: account not verified'); } else if (errno === ERRNO.SESSION_UNVERIFIED) { - // Account not verified. Trying again later. - console.info('Account not verified. Try upgrade later.'); + console.info('Key stretch upgrade deferred: session not verified'); } else { - console.info('Unexpected errno. Try upgrade later.', errno); + console.info(`Key stretch upgrade deferred: unexpected error (errno: ${errno})`); } - // Unexpected state. Log it sentry, and try again later. Sentry.captureMessage( `Failure to finish v2 key-stretching upgrade. Could not start password change during ${this.stage}`, { @@ -213,14 +208,9 @@ export class GqlKeyStretchUpgrade { private async getWrappedKeys(keyFetchToken: string) { try { - const keysResponse = await this.gqlGetWrappedKeys({ - variables: { - input: keyFetchToken, - }, - }); - const wrapKB = keysResponse.data?.wrappedAccountKeys.wrapKB || ''; + const response = await this.authClient.wrappedAccountKeys(keyFetchToken); return { - wrapKB, + wrapKB: response.wrapKB || '', }; } catch (error) { Sentry.captureMessage( @@ -254,21 +244,18 @@ export class GqlKeyStretchUpgrade { 'sessionToken' ); - const input = { - passwordChangeToken: passwordChangeToken, - authPW: v1Credentials.authPW, - wrapKb: keys.wrapKb, - authPWVersion2: v2Credentials.authPW, - wrapKbVersion2: keys.wrapKbVersion2, - clientSalt: v2Credentials.clientSalt, - sessionToken: credentials.id, - }; - - await this.gqlPasswordChangeFinish({ - variables: { - input, + await this.authClient.passwordChangeFinish( + passwordChangeToken, + { + authPW: v1Credentials.authPW, + wrapKb: keys.wrapKb, + authPWVersion2: v2Credentials.authPW, + wrapKbVersion2: keys.wrapKbVersion2, + clientSalt: v2Credentials.clientSalt, + sessionToken: credentials.id, }, - }); + { keys: false } + ); } catch (error) { Sentry.captureMessage( `Failure to finish v2 key-stretching upgrade. Could not finish password change during ${this.stage}`, diff --git a/packages/fxa-settings/src/lib/cache.ts b/packages/fxa-settings/src/lib/cache.ts index 74f47d689d3..50632b2b236 100644 --- a/packages/fxa-settings/src/lib/cache.ts +++ b/packages/fxa-settings/src/lib/cache.ts @@ -2,44 +2,52 @@ * 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 { InMemoryCache, gql } from '@apollo/client'; import Storage from './storage'; -import { Email } from '../models'; import { searchParam } from '../lib/utilities'; -import config from './config'; import { StoredAccountData } from './storage-utils'; import { v4 as uuid } from 'uuid'; import * as Sentry from '@sentry/browser'; import { Constants } from './constants'; import { MfaScope } from './types'; import { AuthUiErrors } from './auth-errors/auth-errors'; +import { dispatchStorageEvent } from './account-storage'; const storage = Storage.factory('localStorage'); +let _isSigningOut = false; + +export function setSigningOut(value: boolean): void { + _isSigningOut = value; +} + +export function isSigningOut(): boolean { + return _isSigningOut; +} + // TODO in FXA-8454 // Add checks to ensure this function cannot produce an object that would violate type safety. // Currently, there are no checks to ensure that the values are defined and non-null, // which could result in errors at runtime. -export function getStoredAccountData({ - uid, - sessionToken, - alertText, - displayName, - metricsEnabled, - lastLogin, - email, - emailVerified, - sessionVerified, -}: Record): StoredAccountData { +export function getStoredAccountData(input: { + uid: hexstring; + sessionToken?: hexstring; + alertText?: string; + displayName?: string; + metricsEnabled?: boolean; + lastLogin?: number; + email?: string; + emailVerified?: boolean; + sessionVerified?: boolean; +}): StoredAccountData { return { - uid, - sessionToken, - alertText, - displayName, - metricsEnabled, - lastLogin, - email, - verified: emailVerified && sessionVerified, + uid: input.uid, + sessionToken: input.sessionToken, + alertText: input.alertText, + displayName: input.displayName, + metricsEnabled: input.metricsEnabled, + lastLogin: input.lastLogin, + email: input.email, + verified: !!(input.emailVerified && input.sessionVerified), }; } @@ -139,6 +147,8 @@ export function clearSignedInAccountUid() { delete all[uid]; accounts(all); storage.remove('currentAccountUid'); + dispatchStorageEvent('accounts'); + dispatchStorageEvent('currentAccountUid'); } /** @@ -206,54 +216,20 @@ export function consumeAlertTextExternal() { return text; } -// sessionToken is added as a local field as an example. -export const typeDefs = gql` - extend type Account { - primaryEmail: Email! - } - extend type Session { - token: String! - } -`; - -export const cache = new InMemoryCache({ - typePolicies: { - Account: { - fields: { - primaryEmail: { - read(_, o) { - const emails = o.readField('emails'); - return emails?.find((email) => email.isPrimary); - }, - }, - }, - keyFields: [], - }, - Avatar: { - fields: { - isDefault: { - read(_, o) { - const url = o.readField('url'); - const id = o.readField('id'); - return !!( - url?.startsWith(config.servers.profile.url) || - id?.startsWith('default') - ); - }, - }, - }, - }, - Session: { - fields: { - token: { - read() { - return sessionToken(); - }, - }, - }, - }, +/** + * No-op cache shim for files still referencing the Apollo InMemoryCache. + * TODO: Remove once PostVerify/SetPassword and InlineRecoveryKeySetup are migrated. + */ +export const cache = { + writeQuery(_options: { query: unknown; data: Record }): void {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + modify(_options: { id: string; fields: Record unknown> }): void {}, + identify(_object: { __typename: string }): string { + return ''; }, -}); +}; + +export const typeDefs = undefined; /* * Check that the React enrolled flag in local storage is set to `true`. diff --git a/packages/fxa-settings/src/lib/config.ts b/packages/fxa-settings/src/lib/config.ts index f7aec47c68e..0e9628cbf44 100644 --- a/packages/fxa-settings/src/lib/config.ts +++ b/packages/fxa-settings/src/lib/config.ts @@ -34,9 +34,6 @@ export interface Config { version: string; }; servers: { - gql: { - url: string; - }; auth: { url: string; }; @@ -49,6 +46,9 @@ export interface Config { paymentsNext: { url: string; }; + legalDocs: { + url: string; + }; }; oauth: { clientId: string; @@ -138,9 +138,6 @@ export function getDefault() { sampleRate: 1.0, }, servers: { - gql: { - url: '', - }, auth: { url: '', }, @@ -153,6 +150,9 @@ export function getDefault() { paymentsNext: { url: '', }, + legalDocs: { + url: '', + }, }, oauth: { clientId: '', @@ -253,17 +253,19 @@ export function decode(content: string | null) { export function reset() { const initial = getDefault(); - // This resets any existing default - // keys back to their original value - Object.assign(config, initial); + // Reset existing default keys back to their original values + const initialRecord = initial as unknown as Record; + const configRecord = config as unknown as Record; + for (const key of Object.keys(initialRecord)) { + configRecord[key] = initialRecord[key]; + } - // This removes any foreign keys that - // may have found there way in - Object.keys(config).forEach((key) => { - if (!initial.hasOwnProperty(key)) { - delete (config as any)[key]; + // Remove any foreign keys that may have found their way in + for (const key of Object.keys(configRecord)) { + if (!(key in initialRecord)) { + delete configRecord[key]; } - }); + } } export function update(newData: { [key: string]: any }) { diff --git a/packages/fxa-settings/src/lib/error-utils.ts b/packages/fxa-settings/src/lib/error-utils.ts index 8894b1863b6..b8853bbc26c 100644 --- a/packages/fxa-settings/src/lib/error-utils.ts +++ b/packages/fxa-settings/src/lib/error-utils.ts @@ -2,7 +2,6 @@ * 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 { GraphQLError } from 'graphql'; import * as Sentry from '@sentry/browser'; import { AuthUiError, @@ -47,43 +46,10 @@ const handleAuthUiError = (error: { export const getHandledError = (error: { errno: number; message: string; - graphQLErrors?: GraphQLError[]; }) => { - const graphQLError: GraphQLError | undefined = error.graphQLErrors?.[0]; - if (graphQLError) { - return handleGQLError(graphQLError); - } return handleAuthUiError(error); }; -const handleGQLError = (graphQLError: GraphQLError) => { - const errno = graphQLError.extensions.errno as number; - - if (errno && AuthUiErrorNos[errno]) { - const uiError = { - message: AuthUiErrorNos[errno].message, - errno, - email: - errno === AuthUiErrors.INCORRECT_EMAIL_CASE.errno - ? graphQLError.extensions.email - : undefined, - verificationMethod: - (graphQLError.extensions.verificationMethod as VerificationMethods) || - undefined, - verificationReason: - (graphQLError.extensions.verificationReason as VerificationReasons) || - undefined, - retryAfter: (graphQLError.extensions.retryAfter as number) || undefined, - retryAfterLocalized: - (graphQLError.extensions.retryAfterLocalized as string) || undefined, - }; - return { error: uiError as HandledError }; - } - - // if not a graphQLError or if no localizable message available for error - return { error: AuthUiErrors.UNEXPECTED_ERROR as HandledError }; -}; - /** * Utility function to retrieve the localized auth client error message * - works for throttling errors that include a localized retry after value diff --git a/packages/fxa-settings/src/lib/file-utils-legal.tsx b/packages/fxa-settings/src/lib/file-utils-legal.tsx index 10da1320310..ecfaf2ecdac 100644 --- a/packages/fxa-settings/src/lib/file-utils-legal.tsx +++ b/packages/fxa-settings/src/lib/file-utils-legal.tsx @@ -2,48 +2,120 @@ * 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 { GET_LEGAL_DOC } from '../models'; -import { ApolloClient } from '@apollo/client'; +import config from './config'; +import { determineLocale } from '@fxa/shared/l10n'; +import * as Sentry from '@sentry/browser'; export enum LegalDocFile { privacy = 'mozilla_accounts_privacy_notice', terms = 'firefox_cloud_services_tos', } +/** + * Fetches legal document markdown directly from the legal docs CDN. + */ export const fetchLegalMd = async ( - apolloClient: ApolloClient | undefined, locale: string, file: string ): Promise<{ markdown?: string; error?: string; }> => { - const error = `Something went wrong. Try again later.`; + const errorMsg = `Something went wrong. Try again later.`; - if (apolloClient == null) { - console.error('No apolloClient provided.'); - return { - error, - }; + // Validate file name to prevent path traversal + if (/^[a-zA-Z-_]{1,500}$/.test(file) === false) { + return { error: 'Invalid file name' }; } try { - const result = await apolloClient.query({ - query: GET_LEGAL_DOC, - variables: { input: { locale, file } }, - }); - - if (result?.data?.getLegalDoc?.markdown) { - return { - markdown: result.data.getLegalDoc?.markdown, - }; + const legalDocsUrl = config.servers.legalDocs.url; + + const availableLocales = await getAvailableLocales(legalDocsUrl, file); + if (!availableLocales || availableLocales.length === 0) { + return { markdown: '' }; + } + + const bestLocale = determineLocale(locale, availableLocales)?.replace( + '_', + '-' + ); + + let markdown = await tryGetDoc(legalDocsUrl, bestLocale, file); + + // Fallback: try base locale (e.g., 'de' instead of 'de-DE') + if (!markdown && bestLocale !== bestLocale.replace(/-.*/, '')) { + markdown = await tryGetDoc( + legalDocsUrl, + bestLocale.replace(/-.*/, ''), + file + ); + } + + // Final fallback: try English + if (!markdown && bestLocale !== 'en') { + markdown = await tryGetDoc(legalDocsUrl, 'en', file); } - // If the markdown we got back is empty / invalid error out. - throw new Error(error); + if (markdown) { + return { markdown }; + } + + throw new Error(errorMsg); } catch (err) { - return { - error, - }; + Sentry.captureException(err); + return { error: errorMsg }; } }; + +/** + * Fetches the list of available locales for a legal document. + */ +async function getAvailableLocales( + legalDocsUrl: string, + file: string +): Promise { + try { + const url = `${legalDocsUrl}/${file}_locales.json`; + const response = await fetch(url); + + if (!response.ok) { + return null; + } + + const availableLocales = await response.json(); + return availableLocales as string[]; + } catch { + return null; + } +} + +/** + * Attempts to fetch a legal document for a specific locale. + */ +async function tryGetDoc( + legalDocsUrl: string, + locale: string, + file: string +): Promise { + try { + const url = `${legalDocsUrl}/${locale}/${file}.md`; + const response = await fetch(url); + + if (!response.ok) { + return ''; + } + + const text = await response.text(); + + // The response should be markdown. HTML is returned if the file is not found. + if (text.includes('')) { + return ''; + } + + return text; + } catch (err) { + Sentry.captureException(err); + return ''; + } +} diff --git a/packages/fxa-settings/src/lib/gql.ts b/packages/fxa-settings/src/lib/gql.ts index 65c4df5814f..c97745e8077 100644 --- a/packages/fxa-settings/src/lib/gql.ts +++ b/packages/fxa-settings/src/lib/gql.ts @@ -123,7 +123,7 @@ export const errorHandler: ErrorHandler = ({ cache.modify({ id: cache.identify({ __typename: 'Account' }), fields: { - totp(_, { DELETE }) { + totp(_: unknown, { DELETE }: { DELETE: unknown }) { return DELETE; }, }, @@ -230,7 +230,7 @@ export function createApolloClient(gqlServerUri: string) { const errorLink = onError(errorHandler); const apolloClientConfig = { - cache, + cache: cache as unknown as import('@apollo/client').InMemoryCache, link: from([ errorLink, sentryLink, diff --git a/packages/fxa-settings/src/lib/hooks/useAccountData.ts b/packages/fxa-settings/src/lib/hooks/useAccountData.ts index 2d7aa8fd60c..a2d5b58b212 100644 --- a/packages/fxa-settings/src/lib/hooks/useAccountData.ts +++ b/packages/fxa-settings/src/lib/hooks/useAccountData.ts @@ -11,7 +11,7 @@ import { RecoveryPhoneStatus, useAccountState, } from '../../models/contexts/AccountStateContext'; -import { Email, AttachedClient, LinkedAccount, SecurityEvent } from '../../models/Account'; +import { Email, LinkedAccount, SecurityEvent, mapAttachedClient } from '../../models/Account'; import { AccountTotp, AccountBackupCodes, AccountAvatar } from '../interfaces'; import config from '../config'; import { ERRNO } from '@fxa/accounts/errors'; @@ -177,49 +177,6 @@ async function fetchProfileData( } } -/** Shape of a single entry from the /account/attached_clients endpoint. */ -interface RawAttachedClient { - clientId: string; - isCurrentSession: boolean; - userAgent: string; - deviceType: string | null; - deviceId: string | null; - name: string; - lastAccessTime: number; - lastAccessTimeFormatted: string; - approximateLastAccessTime: number; - approximateLastAccessTimeFormatted: string; - location?: { city?: string; country?: string; state?: string; stateCode?: string }; - os: string; - sessionTokenId: string | null; - refreshTokenId: string | null; -} - -/** Maps raw attached clients response to normalized AttachedClient objects. */ -function transformAttachedClientsResponse(response: RawAttachedClient[]): AttachedClient[] { - return response.map((client) => ({ - clientId: client.clientId, - isCurrentSession: client.isCurrentSession, - userAgent: client.userAgent, - deviceType: client.deviceType, - deviceId: client.deviceId, - name: client.name, - lastAccessTime: client.lastAccessTime, - lastAccessTimeFormatted: client.lastAccessTimeFormatted, - approximateLastAccessTime: client.approximateLastAccessTime, - approximateLastAccessTimeFormatted: client.approximateLastAccessTimeFormatted, - location: { - city: client.location?.city || null, - country: client.location?.country || null, - state: client.location?.state || null, - stateCode: client.location?.stateCode || null, - }, - os: client.os, - sessionTokenId: client.sessionTokenId, - refreshTokenId: client.refreshTokenId, - })); -} - /** * Hook for fetching account data from auth-server, profile server, and attached clients. * @throws {InvalidTokenError} When the session token is invalid @@ -285,9 +242,7 @@ export function useAccountData({ } if (attachedClientsResult.status === 'fulfilled') { - accountData.attachedClients = transformAttachedClientsResponse( - attachedClientsResult.value - ); + accountData.attachedClients = attachedClientsResult.value.map(mapAttachedClient); } else { Sentry.captureMessage(`Failed to fetch attached clients: ${attachedClientsResult.reason}`); accountData.attachedClients = []; @@ -314,7 +269,7 @@ export function useAccountData({ switch (field) { case 'attachedClients': { const clients = await authClient.attachedClients(token); - fieldData.attachedClients = transformAttachedClientsResponse(clients); + fieldData.attachedClients = clients.map(mapAttachedClient); break; } case 'displayName': diff --git a/packages/fxa-settings/src/lib/hooks/useTotpReplace/index.tsx b/packages/fxa-settings/src/lib/hooks/useTotpReplace/index.tsx index 2f2a731d980..6dd13601915 100644 --- a/packages/fxa-settings/src/lib/hooks/useTotpReplace/index.tsx +++ b/packages/fxa-settings/src/lib/hooks/useTotpReplace/index.tsx @@ -3,13 +3,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { useEffect, useState } from 'react'; -import { useAccount, useSession } from '../../../models'; +import { useAccount } from '../../../models'; import { TotpInfo } from '../../types'; import { useMfaErrorHandler } from '../../../components/Settings/MfaGuard'; export const useTotpReplace = () => { const account = useAccount(); - const session = useSession(); const handleMfaError = useMfaErrorHandler(); const [totpInfo, setTotpInfo] = useState(); @@ -17,7 +16,8 @@ export const useTotpReplace = () => { const [error, setError] = useState(null); useEffect(() => { - if (!session.verified || !account.totp.verified) { + // User must have existing TOTP to replace it + if (!account.totp.verified) { setLoading(false); return; } @@ -44,7 +44,7 @@ export const useTotpReplace = () => { return () => { cancelled = true; }; - }, [account, session.verified, handleMfaError]); + }, [account, handleMfaError]); return { totpInfo, diff --git a/packages/fxa-settings/src/lib/hooks/useTotpSetup/index.tsx b/packages/fxa-settings/src/lib/hooks/useTotpSetup/index.tsx index ef1be20bffd..b1d1acd1dd8 100644 --- a/packages/fxa-settings/src/lib/hooks/useTotpSetup/index.tsx +++ b/packages/fxa-settings/src/lib/hooks/useTotpSetup/index.tsx @@ -3,25 +3,19 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { useEffect, useState } from 'react'; -import { useAccount, useSession } from '../../../models'; +import { useAccount } from '../../../models'; import { TotpInfo } from '../../types'; import { useMfaErrorHandler } from '../../../components/Settings/MfaGuard'; export const useTotpSetup = () => { const account = useAccount(); const handleMfaError = useMfaErrorHandler(); - const session = useSession(); const [totpInfo, setTotpInfo] = useState(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - if (!session.verified) { - setLoading(false); - return; - } - let cancelled = false; const fetchTotp = async () => { setError(null); @@ -44,7 +38,7 @@ export const useTotpSetup = () => { return () => { cancelled = true; }; - }, [account, session.verified, handleMfaError]); + }, [account, handleMfaError]); return { totpInfo, diff --git a/packages/fxa-settings/src/lib/sensitive-data-client.ts b/packages/fxa-settings/src/lib/sensitive-data-client.ts index 6bed67e9cef..105bb120126 100644 --- a/packages/fxa-settings/src/lib/sensitive-data-client.ts +++ b/packages/fxa-settings/src/lib/sensitive-data-client.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { DecryptedRecoveryKeyData } from 'fxa-auth-client/lib/recoveryKey'; -import { V1Credentials, V2Credentials } from './gql-key-stretch-upgrade'; +import { V1Credentials, V2Credentials } from './auth-key-stretch-upgrade'; export namespace SensitiveData { /** diff --git a/packages/fxa-settings/src/lib/storage-utils.ts b/packages/fxa-settings/src/lib/storage-utils.ts index ec930e2c9aa..b679130a2d0 100644 --- a/packages/fxa-settings/src/lib/storage-utils.ts +++ b/packages/fxa-settings/src/lib/storage-utils.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import Storage from './storage'; +import { dispatchStorageEvent } from './account-storage'; const ORIGINAL_TAB_KEY = 'originalTab'; @@ -54,22 +55,29 @@ export interface StoredAccountData { sessionToken?: hexstring; metricsEnabled?: boolean; verified?: boolean; + sessionVerified?: boolean; alertText?: string; displayName?: string; + hasPassword?: boolean; } /** * Persists account data to localStorage. + * Merges with existing account data to preserve fields not being updated. */ export function persistAccount(accountData: StoredAccountData) { const storage = localStorage(); const uid = accountData.uid; let accounts = storage.get('accounts') || {}; - // add the account to local storage - accounts[uid] = accountData; + const existingAccount = accounts[uid] || {}; + accounts[uid] = { + ...existingAccount, + ...accountData, + }; storage.set('accounts', accounts); + dispatchStorageEvent('accounts'); } /** @@ -89,6 +97,7 @@ export function hasAccount(uid: string) { export function setCurrentAccount(uid: string) { const storage = localStorage(); storage.set('currentAccountUid', uid); + dispatchStorageEvent('currentAccountUid'); } /** diff --git a/packages/fxa-settings/src/models/Account.ts b/packages/fxa-settings/src/models/Account.ts index 56279086736..28ac5a7d1cf 100644 --- a/packages/fxa-settings/src/models/Account.ts +++ b/packages/fxa-settings/src/models/Account.ts @@ -3,7 +3,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import base32Decode from 'base32-decode'; -import { gql, ApolloClient, ApolloError } from '@apollo/client'; import config from '../lib/config'; import AuthClient, { AUTH_PROVIDER, @@ -12,25 +11,20 @@ import AuthClient, { getCredentials, getCredentialsV2, getKeysV2, + AttachedClient as RawAttachedClient, } from 'fxa-auth-client/browser'; import { MetricsContext } from '@fxa/shared/glean'; import { currentAccount, - getStoredAccountData, sessionToken, JwtTokenCache, JwtNotFoundError, + isSigningOut, } from '../lib/cache'; import firefox from '../lib/channels/firefox'; import Storage from '../lib/storage'; import { AuthUiErrorNos, AuthUiErrors } from '../lib/auth-errors/auth-errors'; import { LinkedAccountProviderIds, MfaScope, MozServices } from '../lib/types'; -import { - GET_LOCAL_SIGNED_IN_STATUS, - GET_TOTP_STATUS, - GET_BACKUP_CODES_STATUS, - GET_RECOVERY_PHONE, -} from '../components/App/gql'; import { AccountAvatar, AccountBackupCodes, @@ -38,6 +32,15 @@ import { } from '../lib/interfaces'; import { createSaltV2 } from 'fxa-auth-client/lib/salt'; import { getHandledError } from '../lib/error-utils'; +import { + getFullAccountData, + updateExtendedAccountState, + updateBasicAccountData, + ExtendedAccountState, +} from '../lib/account-storage'; + +/** OAuth token TTL in seconds for profile server requests */ +const PROFILE_OAUTH_TOKEN_TTL_SECONDS = 300; export interface DeviceLocation { city: string | null; @@ -72,8 +75,64 @@ export interface RecoveryKeyBundlePayload { recoveryData: string; } +/** Response shape from auth-client recoveryPhoneGet */ +interface RecoveryPhoneGetResponse { + exists: boolean; + phoneNumber?: string; + nationalFormat?: string; +} + +/** Response shape from auth-client recoveryPhoneAvailable */ +interface RecoveryPhoneAvailableResponse { + available: boolean; +} + + +/** Response shape for auth-client email entries */ +interface RawEmail { + email: string; + isPrimary: boolean; + verified: boolean; +} + +/** Re-export RawAttachedClient for consumers that import from Account */ +export type { RawAttachedClient }; + +/** Helper to extract errno from auth-client errors */ +function getErrno(err: unknown): number | undefined { + if (typeof err === 'object' && err !== null && 'errno' in err) { + return (err as { errno: number }).errno; + } + return undefined; +} + +/** Maps a raw attached client from auth-client to the AttachedClient shape */ +export function mapAttachedClient(c: RawAttachedClient): AttachedClient { + return { + clientId: c.clientId, + isCurrentSession: c.isCurrentSession, + userAgent: c.userAgent, + deviceType: c.deviceType, + deviceId: c.deviceId, + name: c.name, + lastAccessTime: c.lastAccessTime, + lastAccessTimeFormatted: c.lastAccessTimeFormatted, + approximateLastAccessTime: c.approximateLastAccessTime, + approximateLastAccessTimeFormatted: c.approximateLastAccessTimeFormatted, + location: { + city: c.location?.city ?? null, + country: c.location?.country ?? null, + state: c.location?.state ?? null, + stateCode: c.location?.stateCode ?? null, + }, + os: c.os, + sessionTokenId: c.sessionTokenId, + refreshTokenId: c.refreshTokenId, + }; +} + // TODO: why doesn't this match fxa-graphql-api/src/lib/resolvers/types/attachedClient.ts? -// DOUBLE TODO: The fact it doeesn't can cuase type safety issues. See FXA-10326 +// DOUBLE TODO: The fact it doesn't can cause type safety issues. See FXA-10326 export interface AttachedClient { clientId: string; isCurrentSession: boolean; @@ -173,178 +232,18 @@ const DEFAULTS = { totpVerified: undefined, }; -export const GET_PROFILE_INFO = gql` - query GetProfileInfo { - account { - uid - displayName - avatar { - id - url - } - primaryEmail @client - emails { - email - isPrimary - verified - } - } - } -`; - -export const GET_ACCOUNT = gql` - query GetAccount { - account { - uid - displayName - avatar { - id - url - isDefault @client - } - accountCreated - passwordCreated - recoveryKey { - exists - estimatedSyncDeviceCount - } - metricsEnabled - primaryEmail @client - emails { - email - isPrimary - verified - } - attachedClients { - clientId - isCurrentSession - userAgent - deviceType - deviceId - name - lastAccessTime - lastAccessTimeFormatted - approximateLastAccessTime - approximateLastAccessTimeFormatted - location { - city - country - state - stateCode - } - os - sessionTokenId - refreshTokenId - } - totp { - exists - verified - } - backupCodes { - hasBackupCodes - count - } - recoveryPhone { - exists - phoneNumber - nationalFormat - available - } - subscriptions { - created - productName - } - linkedAccounts { - providerId - authAt - enabled - } - } - } -`; - -export const GET_EMAILS = gql` - query GetEmails { - account { - emails { - email - isPrimary - verified - } - } - } -`; - -export const GET_CONNECTED_CLIENTS = gql` - query GetConnectedClients { - account { - attachedClients { - clientId - isCurrentSession - userAgent - deviceType - deviceId - name - lastAccessTime - lastAccessTimeFormatted - approximateLastAccessTime - approximateLastAccessTimeFormatted - location { - city - country - state - stateCode - } - os - sessionTokenId - refreshTokenId - } - } - } -`; - -export const GET_RECOVERY_KEY_EXISTS = gql` - query GetRecoveryKeyExists { - account { - recoveryKey { - exists - } - } - } -`; - -export const GET_SECURITY_EVENTS = gql` - query GetSecurityEvents { - account { - securityEvents { - name - createdAt - verified - } - } - } -`; - -const GET_RECOVERY_BUNDLE = gql` - query GetRecoveryKeyBundle($input: RecoveryKeyBundleInput!) { - getRecoveryKeyBundle(input: $input) { - recoveryData - } - } -`; - export function getNextAvatar( existingId?: string, existingUrl?: string, email?: string, displayName?: string | null ): { id?: string | null; url?: string | null; isDefault: boolean } { - const char = - displayName && /[a-zA-Z0-9]/.test(displayName) - ? displayName[0] - : email - ? email[0] - : '?'; + let char = '?'; + if (displayName && /[a-zA-Z0-9]/.test(displayName)) { + char = displayName[0]; + } else if (email) { + char = email[0]; + } const url = `${config.servers.profile.url}/v1/avatar/${char}`; if ( !existingUrl || @@ -363,17 +262,15 @@ export function getNextAvatar( // I'm fairly certain that we do not need this as Settings does not create a // "default" account model with a set of undefined properties. But there is an // interface that calls for an isDefault impl so here it is. -export const isDefault = (account: Record) => - !Object.keys(DEFAULTS).some((x) => account[x] !== undefined); +export const isDefault = (account: object) => + !Object.keys(DEFAULTS).some((x) => (account as Record)[x] !== undefined); export class Account implements AccountData { private readonly authClient: AuthClient; - private readonly apolloClient: ApolloClient; private _loading: boolean; - constructor(client: AuthClient, apolloClient: ApolloClient) { + constructor(client: AuthClient) { this.authClient = client; - this.apolloClient = apolloClient; this._loading = false; } @@ -381,24 +278,50 @@ export class Account implements AccountData { this._loading = true; try { return await promise; - } catch (e) { - throw e; } finally { this._loading = false; } } private get data(): AccountData { - // readQuery is cache-only by default - const result = this.apolloClient.readQuery<{ account: AccountData }>({ - query: GET_ACCOUNT, - }); - - if (!result?.account) { - throw new Error('Account data not loaded from Apollo cache'); + const accountData = getFullAccountData(); + if (!accountData || !accountData.uid) { + // Return safe defaults during sign-out to prevent re-render errors + if (isSigningOut()) { + return { + uid: '', + displayName: null, + avatar: { id: null, url: null, isDefault: true }, + accountCreated: 0, + passwordCreated: 0, + hasPassword: true, + recoveryKey: { exists: false }, + metricsEnabled: false, + primaryEmail: { email: '', isPrimary: true, verified: false }, + emails: [], + attachedClients: [], + linkedAccounts: [], + totp: { exists: false, verified: false }, + backupCodes: { hasBackupCodes: false, count: 0 }, + recoveryPhone: { exists: false, phoneNumber: null, nationalFormat: null, available: false }, + subscriptions: [], + securityEvents: [], + } as AccountData; + } + throw new Error('Account data not loaded from localStorage'); } - return result.account; + return { + ...accountData, + avatar: accountData.avatar || { id: null, url: null, isDefault: true }, + accountCreated: accountData.accountCreated || 0, + passwordCreated: accountData.passwordCreated || 0, + recoveryKey: accountData.recoveryKey || { exists: false }, + primaryEmail: accountData.primaryEmail || { email: accountData.email || '', isPrimary: true, verified: accountData.verified }, + totp: accountData.totp || { exists: false, verified: false }, + backupCodes: accountData.backupCodes || { hasBackupCodes: false, count: 0 }, + recoveryPhone: accountData.recoveryPhone || { exists: false, phoneNumber: null, nationalFormat: null, available: false }, + } as AccountData; } get loading() { @@ -436,14 +359,11 @@ export class Account implements AccountData { } get hasPassword() { - // This might be requested before account data is ready, - // so default to disabled until we can get a proper read + // Default to true if data is not ready (safer default) try { - return ( - this.data?.passwordCreated != null && this.data.passwordCreated > 0 - ); + return this.data?.hasPassword ?? true; } catch { - return false; + return true; } } @@ -510,32 +430,141 @@ export class Account implements AccountData { | 'recoveryPhone' | 'emails' ) { - let query = GET_ACCOUNT; - switch (field) { - case 'clients': - query = GET_CONNECTED_CLIENTS; - break; - case 'recovery': - query = GET_RECOVERY_KEY_EXISTS; - break; - case 'totp': - query = GET_TOTP_STATUS; - break; - case 'backupCodes': - query = GET_BACKUP_CODES_STATUS; - break; - case 'recoveryPhone': - query = GET_RECOVERY_PHONE; - break; - case 'emails': - query = GET_EMAILS; - break; - } + const token = sessionToken(); + if (!token) return; + await this.withLoadingStatus( - this.apolloClient.query({ - fetchPolicy: 'network-only', - query, - }) + (async () => { + switch (field) { + case 'clients': + const clients = await this.authClient.attachedClients(token); + updateExtendedAccountState({ + attachedClients: clients.map(mapAttachedClient), + }); + break; + case 'recovery': + const recoveryKey = await this.authClient.recoveryKeyExists(token, undefined); + updateExtendedAccountState({ + recoveryKey: { + exists: recoveryKey.exists ?? false, + estimatedSyncDeviceCount: recoveryKey.estimatedSyncDeviceCount, + }, + }); + break; + case 'totp': + const totp = await this.authClient.checkTotpTokenExists(token); + updateExtendedAccountState({ + totp: { exists: totp.exists ?? false, verified: totp.verified ?? false }, + }); + break; + case 'backupCodes': + const codes = await this.authClient.getRecoveryCodesExist(token); + updateExtendedAccountState({ + backupCodes: { hasBackupCodes: codes.hasBackupCodes ?? false, count: codes.count ?? 0 }, + }); + break; + case 'recoveryPhone': + try { + const [phoneResult, availableResult] = await Promise.all([ + this.authClient.recoveryPhoneGet(token).catch((): RecoveryPhoneGetResponse => ({ exists: false })), + this.authClient.recoveryPhoneAvailable(token).catch((): RecoveryPhoneAvailableResponse => ({ available: false })), + ]); + updateExtendedAccountState({ + recoveryPhone: { + exists: phoneResult.exists ?? false, + phoneNumber: phoneResult.phoneNumber || null, + nationalFormat: phoneResult.nationalFormat || null, + available: availableResult.available ?? false, + }, + }); + } catch { + updateExtendedAccountState({ + recoveryPhone: { exists: false, phoneNumber: null, nationalFormat: null, available: false }, + }); + } + break; + case 'emails': + const account = await this.authClient.account(token); + updateExtendedAccountState({ + emails: ((account.emails || []) as RawEmail[]).map((e) => ({ + email: e.email, + isPrimary: e.isPrimary, + verified: e.verified, + })), + }); + break; + case 'securityEvents': + const events = await this.authClient.securityEvents(token); + updateExtendedAccountState({ + securityEvents: (events || []).map((e) => ({ + name: e.name, + createdAt: e.createdAt, + verified: e.verified, + })), + }); + break; + case 'account': + default: + + const [accountData, clientsData, totpData, codesData, keyData, phoneData, phoneAvailable] = + await Promise.allSettled([ + this.authClient.account(token), + this.authClient.attachedClients(token), + this.authClient.checkTotpTokenExists(token), + this.authClient.getRecoveryCodesExist(token), + this.authClient.recoveryKeyExists(token, undefined), + this.authClient.recoveryPhoneGet(token), + this.authClient.recoveryPhoneAvailable(token), + ]); + + const updates: Partial = {}; + + if (accountData.status === 'fulfilled') { + updates.emails = ((accountData.value.emails || []) as RawEmail[]).map((e) => ({ + email: e.email, + isPrimary: e.isPrimary, + verified: e.verified, + })); + updates.accountCreated = accountData.value.createdAt || null; + updates.passwordCreated = accountData.value.passwordCreatedAt || null; + } + + if (clientsData.status === 'fulfilled') { + updates.attachedClients = clientsData.value.map(mapAttachedClient); + } + + if (totpData.status === 'fulfilled') { + updates.totp = { exists: totpData.value.exists ?? false, verified: totpData.value.verified ?? false }; + } + + if (codesData.status === 'fulfilled') { + updates.backupCodes = { hasBackupCodes: codesData.value.hasBackupCodes ?? false, count: codesData.value.count ?? 0 }; + } + + if (keyData.status === 'fulfilled') { + updates.recoveryKey = { + exists: keyData.value.exists ?? false, + estimatedSyncDeviceCount: keyData.value.estimatedSyncDeviceCount, + }; + } + + const isPhoneAvailable = phoneAvailable.status === 'fulfilled' ? (phoneAvailable.value as RecoveryPhoneAvailableResponse).available ?? false : false; + if (phoneData.status === 'fulfilled') { + const phoneResult = phoneData.value as RecoveryPhoneGetResponse; + updates.recoveryPhone = { + exists: phoneResult.exists ?? false, + phoneNumber: phoneResult.phoneNumber || null, + nationalFormat: phoneResult.nationalFormat || null, + available: isPhoneAvailable, + }; + } else { + updates.recoveryPhone = { exists: false, phoneNumber: null, nationalFormat: null, available: isPhoneAvailable }; + } + + updateExtendedAccountState(updates); + break; + } + })() ); } @@ -546,11 +575,17 @@ export class Account implements AccountData { } async getSecurityEvents(): Promise { - const { data } = await this.apolloClient.query({ - fetchPolicy: 'network-only', - query: GET_SECURITY_EVENTS, - }); - return data?.account?.securityEvents ?? []; + const token = sessionToken(); + if (!token) return []; + + const events = await this.authClient.securityEvents(token); + const securityEvents = (events || []).map((e) => ({ + name: e.name, + createdAt: e.createdAt, + verified: e.verified, + })); + updateExtendedAccountState({ securityEvents }); + return securityEvents; } async getRecoveryKeyBundle( @@ -563,22 +598,13 @@ export class Account implements AccountData { const recoveryKeyId = await getRecoveryKeyIdByUid(uint8RecoveryKey, uid); try { - const { data } = await this.apolloClient.query({ - fetchPolicy: 'network-only', - query: GET_RECOVERY_BUNDLE, - variables: { - input: { - accountResetToken, - recoveryKeyId, - }, - }, - }); - const { recoveryData } = data.getRecoveryKeyBundle; - return { recoveryData, recoveryKeyId }; - } catch (err) { - const errno = (err as ApolloError).graphQLErrors[0].extensions?.errno as - | number - | undefined; + const recoveryData = await this.authClient.getRecoveryKey( + accountResetToken, + recoveryKeyId + ); + return { recoveryData: recoveryData.recoveryData, recoveryKeyId }; + } catch (err: unknown) { + const errno = getErrno(err); if (errno && AuthUiErrorNos[errno]) { throw AuthUiErrorNos[errno]; } @@ -616,24 +642,12 @@ export class Account implements AccountData { response.unwrapBKey ); sessionToken(response.sessionToken); - this.apolloClient.cache.writeQuery({ - query: gql` - query UpdatePassword { - account { - passwordCreated - } - session { - verified - } - } - `, - data: { - account: { - passwordCreated: response.authAt * 1000, - __typename: 'Account', - }, - session: { verified: response.sessionVerified, __typename: 'Session' }, - }, + + updateBasicAccountData({ + sessionVerified: response.sessionVerified, + }); + updateExtendedAccountState({ + passwordCreated: response.authAt * 1000, }); } @@ -645,14 +659,9 @@ export class Account implements AccountData { newPassword ) ); - const cache = this.apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - passwordCreated() { - return passwordCreatedResult.passwordCreated; - }, - }, + + updateExtendedAccountState({ + passwordCreated: passwordCreatedResult.passwordCreated, }); } @@ -665,14 +674,9 @@ export class Account implements AccountData { newPassword ) ); - const cache = this.apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - passwordCreated() { - return passwordCreatedResult.passwordCreated; - }, - }, + + updateExtendedAccountState({ + passwordCreated: passwordCreatedResult.passwordCreated, }); } @@ -706,32 +710,17 @@ export class Account implements AccountData { options.metricsContext = metricsContext; } - const result = await this.authClient.passwordForgotSendCode(email, options); - return result; + return this.authClient.passwordForgotSendCode(email, options); } async resetPasswordStatus(passwordForgotToken: string): Promise { try { - await this.apolloClient.mutate({ - mutation: gql` - mutation passwordForgotCodeStatus( - $input: PasswordForgotCodeStatusInput! - ) { - passwordForgotCodeStatus(input: $input) { - tries - } - } - `, - variables: { input: { token: passwordForgotToken } }, - }); - + await this.authClient.passwordForgotStatus(passwordForgotToken); // If the request does not fail, that means that the token has not been // consumed yet return true; - } catch (err) { - const errno = (err as ApolloError).graphQLErrors[0].extensions?.errno as - | number - | undefined; + } catch (err: unknown) { + const errno = getErrno(err); // Invalid token means the user has completed reset password // or that the provided token is stale (expired or replaced with new token) @@ -771,10 +760,8 @@ export class Account implements AccountData { // to a normal reset if a user can't use their key. const { accountResetToken } = await this.authClient.passwordForgotVerifyCode(code, token, { - ...{ - accountResetWithRecoveryKey, - includeRecoveryKeyPrompt, - }, + accountResetWithRecoveryKey, + includeRecoveryKeyPrompt, }); return accountResetToken; } @@ -783,32 +770,15 @@ export class Account implements AccountData { * Verify a passwordForgotToken, which returns an accountResetToken that can * be used to perform the actual password reset. * - * NOTE! and TODO: this is currently unused. We need to update the GQL - * endpoint to accept the `accountResetWithRecoveryKey` option and - * fix graphql-api not reporting the correct IP address. - * * @param token passwordForgotToken * @param code code */ async verifyPasswordForgotToken(token: string, code: string) { try { - const verifyCodeResult = await this.apolloClient.mutate({ - mutation: gql` - mutation passwordForgotVerifyCode( - $input: PasswordForgotVerifyCodeInput! - ) { - passwordForgotVerifyCode(input: $input) { - accountResetToken - } - } - `, - variables: { input: { token, code } }, - }); - return verifyCodeResult.data.passwordForgotVerifyCode; - } catch (err) { - const errno = (err as ApolloError).graphQLErrors[0].extensions?.errno as - | number - | undefined; + const result = await this.authClient.passwordForgotVerifyCode(code, token); + return { accountResetToken: result.accountResetToken }; + } catch (err: unknown) { + const errno = getErrno(err); if (errno && AuthUiErrorNos[errno]) { throw AuthUiErrorNos[errno]; } @@ -836,14 +806,17 @@ export class Account implements AccountData { resetToken?: string, kB?: string, includeRecoveryKeyPrompt = false - ): Promise { + ): Promise<{ + authAt: number; + keyFetchToken: string; + sessionToken: string; + uid: string; + unwrapBKey: string; + unwrapBKeyVersion2?: string; + emailVerified: boolean; + sessionVerified: boolean; + }> { try { - // TODO: Temporary workaround (use auth-client directly) for GraphQL not - // getting correct ip address - // const { accountResetToken } = await this.verifyPasswordForgotToken( - // token, - // code - // ); // if we already have a reset token, that means the user successfully used a recovery key const accountResetToken = resetToken || @@ -877,80 +850,68 @@ export class Account implements AccountData { }; } - const { - data: { accountReset }, - } = await this.apolloClient.mutate({ - mutation: gql` - mutation accountResetAuthPW($input: AccountResetInput!) { - accountReset(input: $input) { - clientMutationId - sessionToken - uid - authAt - keyFetchToken - emailVerified - sessionVerified - } - } - `, - variables: { - input: { - accountResetToken, - newPasswordAuthPW: credentials.authPW, - newPasswordV2, - options: { sessionToken: true, keys: true }, - }, - }, - }); - accountReset.unwrapBKey = credentials.unwrapBKey; - accountReset.unwrapBKeyVersion2 = credentialsV2?.unwrapBKey; - currentAccount(getStoredAccountData(accountReset)); - sessionToken(accountReset.sessionToken); - if (accountReset.sessionVerified) { - this.apolloClient.cache.writeQuery({ - query: GET_LOCAL_SIGNED_IN_STATUS, - data: { isSignedIn: true }, - }); - } - return accountReset; + const accountReset = await this.authClient.accountResetAuthPW( + credentials.authPW, + accountResetToken, + newPasswordV2 ?? {}, + { + sessionToken: true, + keys: true, + } + ); + + // localStorage is handled by the caller via storeAccountData + return { + ...accountReset, + unwrapBKey: credentials.unwrapBKey, + unwrapBKeyVersion2: credentialsV2?.unwrapBKey, + }; } catch (err) { throw getHandledError(err); } } async setDisplayName(displayName: string) { - await this.withLoadingStatus( - this.apolloClient.mutate({ - mutation: gql` - mutation updateDisplayName($input: UpdateDisplayNameInput!) { - updateDisplayName(input: $input) { - clientMutationId - } - } - `, - update: (cache) => { - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - displayName() { - return displayName; - }, - avatar: (existing, { readField }) => { - const id = readField('id', existing); - const oldUrl = readField('url', existing); - return getNextAvatar( - id, - oldUrl, - this.primaryEmail.email, - displayName - ); - }, - }, - }); + const token = sessionToken(); + if (!token) throw AuthUiErrors.INVALID_TOKEN; + + + const { access_token } = await this.withLoadingStatus( + this.authClient.createOAuthToken(token, config.oauth.clientId, { + scope: 'profile:write', + ttl: PROFILE_OAUTH_TOKEN_TTL_SECONDS, + }) + ); + + + const response = await this.withLoadingStatus( + fetch(`${config.servers.profile.url}/v1/display_name`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${access_token}`, }, - variables: { input: { displayName } }, + body: JSON.stringify({ displayName }), }) ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.message || 'Failed to update display name'); + } + + const currentAvatar = this.avatar; + const newAvatar = getNextAvatar( + currentAvatar?.id ?? undefined, + currentAvatar?.url ?? undefined, + this.primaryEmail.email, + displayName + ); + updateExtendedAccountState({ + displayName, + avatar: { ...currentAvatar, ...newAvatar } as AccountAvatar & { isDefault?: boolean }, + }); + const legacyLocalStorageAccount = currentAccount()!; legacyLocalStorageAccount.displayName = displayName; currentAccount(legacyLocalStorageAccount); @@ -962,74 +923,59 @@ export class Account implements AccountData { } async deleteAvatar() { + const token = sessionToken(); + if (!token) throw AuthUiErrors.INVALID_TOKEN; + + const avatarId = this.avatar.id; + + const { access_token } = await this.withLoadingStatus( + this.authClient.createOAuthToken(token, config.oauth.clientId, { + scope: 'profile:write', + ttl: PROFILE_OAUTH_TOKEN_TTL_SECONDS, + }) + ); + await this.withLoadingStatus( - this.apolloClient.mutate({ - mutation: gql` - mutation deleteAvatar($input: DeleteAvatarInput!) { - deleteAvatar(input: $input) { - clientMutationId - } - } - `, - update: (cache) => { - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - avatar: () => { - return getNextAvatar( - undefined, - undefined, - this.primaryEmail.email, - this.displayName - ); - }, - }, - }); + fetch(`${config.servers.profile.url}/v1/avatar/${avatarId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${access_token}`, }, - variables: { input: { id: this.avatar.id } }, }) ); + + const newAvatar = getNextAvatar( + undefined, + undefined, + this.primaryEmail.email, + this.displayName + ); + updateExtendedAccountState({ + avatar: newAvatar as AccountAvatar & { isDefault?: boolean }, + }); + firefox.profileChanged({ uid: this.uid }); } async disconnectClient(client: AttachedClient) { + const token = sessionToken(); + if (!token) throw AuthUiErrors.INVALID_TOKEN; + await this.withLoadingStatus( - this.apolloClient.mutate({ - mutation: gql` - mutation attachedClientDisconnect( - $input: AttachedClientDisconnectInput! - ) { - attachedClientDisconnect(input: $input) { - clientMutationId - } - } - `, - update: (cache) => { - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - attachedClients: (existingClients) => { - const updatedList = [...existingClients]; - return updatedList.filter( - // TODO: should this also go into the AttachedClient model? - (c) => - c.lastAccessTime !== client.lastAccessTime && - c.name !== client.name - ); - }, - }, - }); - }, - variables: { - input: { - clientId: client.clientId, - deviceId: client.deviceId, - sessionTokenId: client.sessionTokenId, - refreshTokenId: client.refreshTokenId, - }, - }, + this.authClient.attachedClientDestroy(token, { + clientId: client.clientId, + deviceId: client.deviceId, + sessionTokenId: client.sessionTokenId, + refreshTokenId: client.refreshTokenId, }) ); + + const currentClients = this.attachedClients; + const updatedClients = currentClients.filter( + (c) => + c.lastAccessTime !== client.lastAccessTime && c.name !== client.name + ); + updateExtendedAccountState({ attachedClients: updatedClients }); } async verifyAccountThirdParty( @@ -1044,7 +990,7 @@ export class Account implements AccountData { email: string; verificationMethod?: string; }> { - const linkedAccount = await this.withLoadingStatus( + return this.withLoadingStatus( this.authClient.verifyAccountThirdParty( code, provider, @@ -1052,8 +998,6 @@ export class Account implements AccountData { metricsContext ) ); - - return linkedAccount; } // This handler replaces the recovery codes in one step without requiring confirming @@ -1136,15 +1080,10 @@ export class Account implements AccountData { this.authClient.deleteTotpTokenWithJwt(this.getCachedJwtByScope('2fa')) ); - const cache = this.apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - totp() { - return { exists: false, verified: false }; - }, - }, + updateExtendedAccountState({ + totp: { exists: false, verified: false }, }); + await this.refresh('recoveryPhone'); await this.refresh('backupCodes'); } @@ -1152,16 +1091,12 @@ export class Account implements AccountData { async deleteRecoveryKeyWithJwt() { const jwt = this.getCachedJwtByScope('recovery_key'); await this.withLoadingStatus(this.authClient.deleteRecoveryKeyWithJwt(jwt)); - const cache = this.apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - recoveryKey(existingData) { - return { - exists: false, - estimatedSyncDeviceCount: existingData.estimatedSyncDeviceCount, - }; - }, + + const currentRecoveryKey = this.recoveryKey; + updateExtendedAccountState({ + recoveryKey: { + exists: false, + estimatedSyncDeviceCount: currentRecoveryKey?.estimatedSyncDeviceCount, }, }); } @@ -1179,20 +1114,18 @@ export class Account implements AccountData { this.authClient.recoveryEmailSetPrimaryEmail(sessionToken()!, email) ); await this.refresh('emails'); - const cache = this.apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - primaryEmail() { - return { email, isPrimary: true, verified: true }; - }, - avatar: (existing, { readField }) => { - const id = readField('id', existing); - const oldUrl = readField('url', existing); - return getNextAvatar(id, oldUrl, email, this.displayName); - }, - }, + + const currentAvatar = this.avatar; + const newAvatar = getNextAvatar( + currentAvatar?.id ?? undefined, + currentAvatar?.url ?? undefined, + email, + this.displayName + ); + updateExtendedAccountState({ + avatar: { ...currentAvatar, ...newAvatar } as AccountAvatar & { isDefault?: boolean }, }); + updateBasicAccountData({ email }); const legacyLocalStorageAccount = currentAccount()!; legacyLocalStorageAccount.email = email; @@ -1207,20 +1140,18 @@ export class Account implements AccountData { this.authClient.recoveryEmailSetPrimaryEmailWithJwt(jwt, email) ); await this.refresh('emails'); - const cache = this.apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - primaryEmail() { - return { email, isPrimary: true, verified: true }; - }, - avatar: (existing, { readField }) => { - const id = readField('id', existing); - const oldUrl = readField('url', existing); - return getNextAvatar(id, oldUrl, email, this.displayName); - }, - }, + + const currentAvatar = this.avatar; + const newAvatar = getNextAvatar( + currentAvatar?.id ?? undefined, + currentAvatar?.url ?? undefined, + email, + this.displayName + ); + updateExtendedAccountState({ + avatar: { ...currentAvatar, ...newAvatar } as AccountAvatar & { isDefault?: boolean }, }); + updateBasicAccountData({ email }); const legacyLocalStorageAccount = currentAccount()!; legacyLocalStorageAccount.email = email; @@ -1243,13 +1174,12 @@ export class Account implements AccountData { } async createTotpWithJwt() { - const totp = await this.withLoadingStatus( + return this.withLoadingStatus( this.authClient.createTotpTokenWithJwt( this.getCachedJwtByScope('2fa'), {} ) ); - return totp; } async verifyTotpSetupCodeWithJwt(code: string) { @@ -1273,10 +1203,9 @@ export class Account implements AccountData { async startReplaceTotpWithJwt() { const jwt = this.getCachedJwtByScope('2fa'); - const totp = await this.withLoadingStatus( + return this.withLoadingStatus( this.authClient.startReplaceTotpTokenWithJwt(jwt, {}) ); - return totp; } async confirmReplaceTotpWithJwt(code: string) { @@ -1290,7 +1219,7 @@ export class Account implements AccountData { const { access_token } = await this.withLoadingStatus( this.authClient.createOAuthToken(sessionToken()!, config.oauth.clientId, { scope: 'profile:write clients:write', - ttl: 300, + ttl: PROFILE_OAUTH_TOKEN_TTL_SECONDS, }) ); const response = await this.withLoadingStatus( @@ -1307,15 +1236,11 @@ export class Account implements AccountData { throw new Error(`${response.status}`); } const newAvatar = (await response.json()) as Account['avatar']; - const cache = this.apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - avatar() { - return { ...newAvatar, isDefault: false }; - }, - }, + + updateExtendedAccountState({ + avatar: { ...newAvatar, isDefault: false }, }); + firefox.profileChanged({ uid: this.uid }); } @@ -1364,16 +1289,11 @@ export class Account implements AccountData { ); } - const cache = this.apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - recoveryKey(existingData) { - return { - exists: true, - estimatedSyncDeviceCount: existingData.estimatedSyncDeviceCount, - }; - }, + const currentRecoveryKey = this.recoveryKey; + updateExtendedAccountState({ + recoveryKey: { + exists: true, + estimatedSyncDeviceCount: currentRecoveryKey?.estimatedSyncDeviceCount, }, }); return recoveryKey; @@ -1386,28 +1306,15 @@ export class Account implements AccountData { } async metricsOpt(state: 'in' | 'out') { + const token = sessionToken(); + if (!token) throw AuthUiErrors.INVALID_TOKEN; + await this.withLoadingStatus( - this.apolloClient.mutate({ - mutation: gql` - mutation metricsOpt($input: MetricsOptInput!) { - metricsOpt(input: $input) { - clientMutationId - } - } - `, - update: (cache) => { - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - metricsEnabled: () => { - return state === 'in'; - }, - }, - }); - }, - variables: { input: { state } }, - }) + this.authClient.metricsOpt(token, state) ); + + updateBasicAccountData({ metricsEnabled: state === 'in' }); + const legacyLocalStorageAccount = currentAccount()!; legacyLocalStorageAccount.metricsEnabled = state === 'in'; currentAccount(legacyLocalStorageAccount); @@ -1419,17 +1326,11 @@ export class Account implements AccountData { this.authClient.unlinkThirdParty(sessionToken()!, providerId) ); - const cache = this.apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - linkedAccounts: (existingAccounts) => { - return existingAccounts.filter((linkedAcc: LinkedAccount) => { - return linkedAcc.providerId !== providerId; - }); - }, - }, - }); + const currentLinkedAccounts = this.linkedAccounts; + const updatedLinkedAccounts = currentLinkedAccounts.filter( + (linkedAcc) => linkedAcc.providerId !== providerId + ); + updateExtendedAccountState({ linkedAccounts: updatedLinkedAccounts }); } async destroy(password: string) { @@ -1453,7 +1354,7 @@ export class Account implements AccountData { kB: string; isFirefoxMobileClient: boolean; }) { - const data = await this.authClient.resetPasswordWithRecoveryKey( + const response = await this.authClient.resetPasswordWithRecoveryKey( opts.accountResetToken, opts.emailToHashWith, opts.password, @@ -1465,63 +1366,48 @@ export class Account implements AccountData { isFirefoxMobileClient: opts.isFirefoxMobileClient, } ); - currentAccount(currentAccount(getStoredAccountData(data))); - sessionToken(data.sessionToken); - const cache = this.apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - recoveryKey(existingData) { - return { - exists: false, - estimatedSyncDeviceCount: existingData.estimatedSyncDeviceCount, - }; - }, - }, - }); - cache.writeQuery({ - query: GET_LOCAL_SIGNED_IN_STATUS, - data: { isSignedIn: true }, - }); - return data; + + if (response.sessionToken) { + sessionToken(response.sessionToken); + updateBasicAccountData({ + sessionVerified: response.verified, + }); + } + + return response; } async removeRecoveryPhone() { const jwt = this.getCachedJwtByScope('2fa'); - const result = await this.withLoadingStatus( + return this.withLoadingStatus( this.authClient.recoveryPhoneDeleteWithJwt(jwt) ); - return result; } async addRecoveryPhoneWithJwt(phoneNumber: string) { const jwt = this.getCachedJwtByScope('2fa'); - const result = await this.withLoadingStatus( + return this.withLoadingStatus( this.authClient.recoveryPhoneCreateWithJwt(jwt, phoneNumber) ); - return result; } async addRecoveryPhone(phoneNumber: string) { - const result = await this.withLoadingStatus( + return this.withLoadingStatus( this.authClient.recoveryPhoneCreate(sessionToken()!, phoneNumber) ); - return result; } async changeRecoveryPhoneWithJwt(code: string) { const jwt = this.getCachedJwtByScope('2fa'); - const result = await this.withLoadingStatus( + return this.withLoadingStatus( this.authClient.recoveryPhoneChangeWithJwt(jwt, code) ); - return result; } async changeRecoveryPhone(code: string) { - const result = await this.withLoadingStatus( + return this.withLoadingStatus( this.authClient.recoveryPhoneChange(sessionToken()!, code) ); - return result; } async confirmRecoveryPhoneWithJwt(code: string) { @@ -1537,18 +1423,13 @@ export class Account implements AccountData { const { nationalFormat } = await this.withLoadingStatus( this.authClient.recoveryPhoneConfirmSetup(sessionToken()!, code) ); - const cache = this.apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - recoveryPhone() { - return { - exists: true, - phoneNumber, - nationalFormat, - available: true, - }; - }, + + updateExtendedAccountState({ + recoveryPhone: { + exists: true, + phoneNumber, + nationalFormat, + available: true, }, }); } diff --git a/packages/fxa-settings/src/models/AlertBarInfo.ts b/packages/fxa-settings/src/models/AlertBarInfo.ts index 51740c4096c..4b0f2f469f9 100644 --- a/packages/fxa-settings/src/models/AlertBarInfo.ts +++ b/packages/fxa-settings/src/models/AlertBarInfo.ts @@ -2,7 +2,7 @@ * 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 { makeVar } from '@apollo/client'; +import { makeVar } from '../lib/reactive-var'; import { ReactNode } from 'react'; import { consumeAlertTextExternal } from '../lib/cache'; diff --git a/packages/fxa-settings/src/models/Legal.ts b/packages/fxa-settings/src/models/Legal.ts deleted file mode 100644 index 9b7688f4de7..00000000000 --- a/packages/fxa-settings/src/models/Legal.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * 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 { gql } from '@apollo/client'; - -export interface LegalDoc { - markdown: string; -} - -export const GET_LEGAL_DOC = gql` - query GetLegalDoc($input: LegalInput!) { - getLegalDoc(input: $input) { - markdown - } - } -`; diff --git a/packages/fxa-settings/src/models/Session.ts b/packages/fxa-settings/src/models/Session.ts index a2c91c989ac..25f87a1398b 100644 --- a/packages/fxa-settings/src/models/Session.ts +++ b/packages/fxa-settings/src/models/Session.ts @@ -2,14 +2,18 @@ * 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 { ApolloClient, gql, NormalizedCacheObject } from '@apollo/client'; import AuthClient from 'fxa-auth-client/browser'; import { sessionToken, clearSignedInAccountUid, currentAccount, } from '../lib/cache'; -import { GET_LOCAL_SIGNED_IN_STATUS } from '../components/App/gql'; +import { + isSignedIn as checkIsSignedIn, + getSessionVerified, + setSessionVerified, + dispatchStorageEvent, +} from '../lib/account-storage'; export interface SessionData { verified: boolean | null; @@ -26,39 +30,31 @@ export interface SessionData { destroy?: () => void; } -export const GET_SESSION_VERIFIED = gql` - query GetSession { - session { - verified - } - } -`; - -export const GET_SESSION_IS_VALID = gql` - query GetSessionIsValid($sessionToken: String!) { - isValidToken(sessionToken: $sessionToken) - } -`; +/** + * Check if user is signed in (derived from account storage) + */ +export function getStoredSignedInStatus(): boolean { + return checkIsSignedIn(); +} -export const DESTROY_SESSION = gql` - mutation DestroySession { - destroySession(input: {}) { - clientMutationId - } +/** + * Mark session as verified in account storage + * Note: "signed in" status is derived from having a sessionToken, + * so this function primarily marks the session as verified. + */ +export function setStoredSignedInStatus(isSignedIn: boolean): void { + if (isSignedIn) { + setSessionVerified(true); } -`; + dispatchStorageEvent('isSignedIn'); +} export class Session implements SessionData { private readonly authClient: AuthClient; - private readonly apolloClient: ApolloClient; private _loading: boolean; - constructor( - authClient: AuthClient, - apolloClient: ApolloClient - ) { + constructor(authClient: AuthClient) { this.authClient = authClient; - this.apolloClient = apolloClient; this._loading = false; } @@ -66,32 +62,19 @@ export class Session implements SessionData { this._loading = true; try { return await promise; - } catch (e) { - throw e; } finally { this._loading = false; } } - private get data(): Session | undefined { - const result = this.apolloClient.cache.readQuery<{ - session: Session; - }>({ - query: GET_SESSION_VERIFIED, - }); - - return result?.session; - } - get token(): string { - return this.data?.token || ''; + return sessionToken() || ''; } get verified(): boolean { - return this.data?.verified || false; + return getSessionVerified(); } - // TODO: Use GQL verifyCode instead of authClient async verifySession( code: string, options: { @@ -104,17 +87,8 @@ export class Session implements SessionData { await this.withLoadingStatus( this.authClient.sessionVerifyCode(sessionToken()!, code, options) ); - this.apolloClient.cache.modify({ - fields: { - session: () => { - return true; - }, - }, - }); - this.apolloClient.cache.writeQuery({ - query: GET_LOCAL_SIGNED_IN_STATUS, - data: { isSignedIn: true }, - }); + setSessionVerified(true); + setStoredSignedInStatus(true); } async sendVerificationCode() { @@ -124,55 +98,43 @@ export class Session implements SessionData { } async destroy() { - await this.apolloClient.mutate({ - mutation: DESTROY_SESSION, - variables: { input: {} }, - }); - + const token = sessionToken(); + if (token) { + await this.authClient.sessionDestroy(token); + } clearSignedInAccountUid(); + dispatchStorageEvent('isSignedIn'); } get isDestroyed() { return currentAccount() == null; } - async isSessionVerified() { - const query = GET_SESSION_VERIFIED; - const { data } = await this.apolloClient.query({ - fetchPolicy: 'network-only', - query, - }); - - const { session } = data; - const sessionStatus: boolean = session.verified; - - this.apolloClient.cache.modify({ - fields: { - session: () => { - return sessionStatus; - }, - }, - }); - return sessionStatus; + async isSessionVerified(): Promise { + const token = sessionToken(); + if (!token) { + return false; + } + + try { + const status = await this.authClient.sessionStatus(token); + const verified = status.state === 'verified'; + setSessionVerified(verified); + return verified; + } catch (e) { + setSessionVerified(false); + return false; + } } - async isValid(sessionToken: string) { - // If the current session token is valid, the following query will succeed. - // If current session is not valid an 'Invalid Token' error will be thrown. - const query = GET_SESSION_IS_VALID; - const { data } = await this.apolloClient.query({ - fetchPolicy: 'network-only', - query, - variables: { sessionToken }, - }); - if (data?.isValidToken === true) { - this.apolloClient.cache.writeQuery({ - query: GET_LOCAL_SIGNED_IN_STATUS, - data: { isSignedIn: true }, - }); + async isValid(token: string): Promise { + try { + await this.authClient.sessionStatus(token); + setSessionVerified(true); return true; + } catch (e) { + setSessionVerified(false); + return false; } - - return false; } } diff --git a/packages/fxa-settings/src/models/contexts/AppContext.ts b/packages/fxa-settings/src/models/contexts/AppContext.ts index 5253112ce1a..9963a369242 100644 --- a/packages/fxa-settings/src/models/contexts/AppContext.ts +++ b/packages/fxa-settings/src/models/contexts/AppContext.ts @@ -2,13 +2,11 @@ * 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 { ApolloClient } from '@apollo/client'; import AuthClient from 'fxa-auth-client/browser'; import React from 'react'; import config, { Config, readConfigMeta, getDefault } from '../../lib/config'; -import { createApolloClient } from '../../lib/gql'; import { Account } from '../Account'; -import { Session } from '../Session'; +import { Session, setStoredSignedInStatus } from '../Session'; import { AlertBarInfo } from '../AlertBarInfo'; import { KeyStretchExperiment } from '../experiments/key-stretch-experiment'; import { UrlQueryData } from '../../lib/model-data'; @@ -17,13 +15,12 @@ import { SensitiveDataClient } from '../../lib/sensitive-data-client'; import { currentAccount, getUniqueUserId } from '../../lib/cache'; import { AuthUiErrors, isAuthUiError } from '../../lib/auth-errors/auth-errors'; import { navigateWithQuery } from '../../lib/utilities'; -import { GET_LOCAL_SIGNED_IN_STATUS } from '../../components/App/gql'; +import { updateExtendedAccountState } from '../../lib/account-storage'; // TODO, move some values from AppContext to SettingsContext after // using container components, FXA-8107 export interface AppContextValue { authClient?: AuthClient; - apolloClient?: ApolloClient; sensitiveDataClient?: SensitiveDataClient; // used for sensitive data that needs to be encrypted between components config?: Config; account?: Account; @@ -45,8 +42,6 @@ export function initializeAppContext() { new UrlQueryData(new ReachRouterWindow()) ); - const apolloClient = createApolloClient(config.servers.gql.url); - const authClient = new AuthClient(config.servers.auth.url, { keyStretchVersion: keyStretchExperiment.isV2(config) ? 2 : 1, errorHandler: async (error) => { @@ -55,15 +50,7 @@ export function initializeAppContext() { error.errno === AuthUiErrors.INSUFFICIENT_AAL.errno && window?.location.pathname.includes('settings') ) { - const cache = apolloClient.cache; - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - totp(_, { DELETE }) { - return DELETE; - }, - }, - }); + updateExtendedAccountState({ totp: null }); const storedAccount = currentAccount(); await navigateWithQuery('/signin_totp_code', { @@ -79,24 +66,20 @@ export function initializeAppContext() { error.errno === AuthUiErrors.INVALID_TOKEN.errno && window?.location.pathname.includes('settings') ) { - apolloClient.cache.writeQuery({ - query: GET_LOCAL_SIGNED_IN_STATUS, - data: { isSignedIn: false }, - }); + setStoredSignedInStatus(false); } } throw error; }, }); - const account = new Account(authClient, apolloClient); - const session = new Session(authClient, apolloClient); + const account = new Account(authClient); + const session = new Session(authClient); const sensitiveDataClient = new SensitiveDataClient(); const uniqueUserId = getUniqueUserId(); const context: AppContextValue = { authClient, - apolloClient, config, account, session, @@ -158,14 +141,12 @@ export function defaultAppContext(context?: AppContextValue) { verified: true, token: 'deadc0de', }; - return Object.assign( - { - account, - session, - config: getDefault(), - }, - context - ) as AppContextValue; + return { + account, + session, + config: getDefault(), + ...context, + } as AppContextValue; } export const AppContext = diff --git a/packages/fxa-settings/src/models/contexts/SettingsContext.ts b/packages/fxa-settings/src/models/contexts/SettingsContext.ts index cdd09a06e15..41bb80bdfbe 100644 --- a/packages/fxa-settings/src/models/contexts/SettingsContext.ts +++ b/packages/fxa-settings/src/models/contexts/SettingsContext.ts @@ -2,87 +2,10 @@ * 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 { gql } from '@apollo/client'; import React from 'react'; -import config from '../../lib/config'; import firefox, { FirefoxCommand } from '../../lib/channels/firefox'; -import { createApolloClient } from '../../lib/gql'; -import { GET_PROFILE_INFO } from '../Account'; import { AlertBarInfo } from '../AlertBarInfo'; - -export const INITIAL_SETTINGS_QUERY = gql` - query GetInitialSettingsState { - account { - uid - displayName - avatar { - id - url - isDefault @client - } - accountCreated - passwordCreated - recoveryKey { - exists - estimatedSyncDeviceCount - } - metricsEnabled - primaryEmail @client - emails { - email - isPrimary - verified - } - attachedClients { - clientId - isCurrentSession - userAgent - deviceType - deviceId - name - lastAccessTime - lastAccessTimeFormatted - approximateLastAccessTime - approximateLastAccessTimeFormatted - location { - city - country - state - stateCode - } - os - sessionTokenId - refreshTokenId - } - totp { - exists - verified - } - backupCodes { - hasBackupCodes - count - } - recoveryPhone { - exists - phoneNumber - nationalFormat - available - } - subscriptions { - created - productName - } - linkedAccounts { - providerId - authAt - enabled - } - } - session { - verified - } - } -`; +import { getCurrentAccountUid, updateExtendedAccountState, dispatchStorageEvent } from '../../lib/account-storage'; // TODO, move some values from AppContext to SettingsContext after // using container components, FXA-8107 @@ -93,61 +16,34 @@ export interface SettingsContextValue { export function initializeSettingsContext() { const alertBarInfo = new AlertBarInfo(); - const apolloClient = createApolloClient(config.servers.gql.url); - - const GET_UID_QUERY = gql` - query GetUid { - account { - uid - } - } - `; const isForCurrentUser = (event: Event) => { - const data = apolloClient.readQuery<{ account: { uid: string } }>({ - query: GET_UID_QUERY, - }); - - if (!data?.account?.uid) { + const currentUid = getCurrentAccountUid(); + if (!currentUid) { return false; } - const currentUid = data.account.uid; const eventUid = (event as CustomEvent).detail?.uid; - return currentUid != null && currentUid === eventUid; + return currentUid === eventUid; }; firefox.addEventListener(FirefoxCommand.ProfileChanged, (event) => { if (isForCurrentUser(event)) { - apolloClient.query({ - query: GET_PROFILE_INFO, - fetchPolicy: 'network-only', - }); + dispatchStorageEvent('profileChanged'); } }); + firefox.addEventListener(FirefoxCommand.PasswordChanged, (event) => { if (isForCurrentUser(event)) { - apolloClient.writeQuery({ - query: gql` - query UpdatePasswordCreated { - account { - passwordCreated - } - } - `, - data: { - account: { - passwordCreated: Date.now(), - __typename: 'Account', - }, - }, - }); + updateExtendedAccountState({ passwordCreated: Date.now() }); } }); + firefox.addEventListener(FirefoxCommand.AccountDeleted, (event) => { if (isForCurrentUser(event)) { window.location.assign('/'); } }); + firefox.addEventListener(FirefoxCommand.Error, (event) => { console.error(event); }); @@ -161,13 +57,11 @@ export function initializeSettingsContext() { } export function defaultSettingsContext(context?: SettingsContextValue) { - return Object.assign( - { - alertBarInfo: new AlertBarInfo(), - navigatorLanguages: navigator.languages || ['en'], - }, - context - ) as SettingsContextValue; + return { + alertBarInfo: new AlertBarInfo(), + navigatorLanguages: navigator.languages || ['en'], + ...context, + } as SettingsContextValue; } export const SettingsContext = React.createContext( diff --git a/packages/fxa-settings/src/models/hooks.test.ts b/packages/fxa-settings/src/models/hooks.test.ts index 1967e2c595a..71acd147c4c 100644 --- a/packages/fxa-settings/src/models/hooks.test.ts +++ b/packages/fxa-settings/src/models/hooks.test.ts @@ -83,7 +83,6 @@ const MockAppProvider = ({ children }: { children: ReactNode }) => value: { config: mockConfig as any, account: undefined, - apolloClient: undefined, }, }, children @@ -192,7 +191,6 @@ describe('useCmsInfoState', () => { value: { config: disabledConfig as any, account: undefined, - apolloClient: undefined, }, }, children @@ -413,7 +411,6 @@ describe('useCmsInfoState', () => { value: { config: l10nEnabledConfig as any, account: undefined, - apolloClient: undefined, }, }, children diff --git a/packages/fxa-settings/src/models/hooks.ts b/packages/fxa-settings/src/models/hooks.ts index 93ca3f9ff28..77716addab6 100644 --- a/packages/fxa-settings/src/models/hooks.ts +++ b/packages/fxa-settings/src/models/hooks.ts @@ -7,11 +7,7 @@ import { isHexadecimal, length } from 'class-validator'; import { AppContext } from './contexts/AppContext'; import { useNimbusContext } from './contexts/NimbusContext'; import { NimbusResult } from '../lib/nimbus'; -import { - INITIAL_SETTINGS_QUERY, - SettingsContext, -} from './contexts/SettingsContext'; -import { useQuery } from '@apollo/client'; +import { SettingsContext } from './contexts/SettingsContext'; import { useLocalization } from '@fluent/react'; import { FtlMsgResolver } from 'fxa-react/lib/utils'; import { getDefault } from '../lib/config'; @@ -21,16 +17,7 @@ import { } from '../lib/integrations'; import { ReachRouterWindow } from '../lib/window'; import { StorageData, UrlHashData, UrlQueryData } from '../lib/model-data'; -import { - GET_LOCAL_SIGNED_IN_STATUS, - INITIAL_METRICS_QUERY, - GET_PRODUCT_INFO, - GET_CLIENT_INFO, -} from '../components/App/gql'; -import { - MetricsDataResult, - SignedInAccountStatus, -} from '../components/App/interfaces'; +import { MetricsData, SignedInAccountStatus } from '../components/App/interfaces'; import { RelierClientInfo, RelierSubscriptionInfo, @@ -39,6 +26,9 @@ import { } from './integrations'; import * as Sentry from '@sentry/browser'; import { useDynamicLocalization } from '../contexts/DynamicLocalizationContext'; +import { sessionToken } from '../lib/cache'; +import { useLocalStorageSync } from '../lib/hooks/useLocalStorageSync'; +import { getFullAccountData, isSignedIn as checkIsSignedIn } from '../lib/account-storage'; const DEFAULT_CMS_ENTRYPOINT = 'default'; @@ -57,7 +47,7 @@ export function useAccount() { return account; } -function getMissing(obj: any) { +function getMissing(obj: Record) { const missingKeys: string[] = []; for (const x of Object.keys(obj)) { if (obj[x] == null) { @@ -162,41 +152,172 @@ export function useConfig() { return config; } -export function useInitialSettingsState() { - const { apolloClient } = useContext(AppContext); - if (!apolloClient) { - throw new Error('Are you forgetting an AppContext.Provider?'); - } - return useQuery(INITIAL_SETTINGS_QUERY, { client: apolloClient }); -} - -// TODO: FXA-8286, test pattern for container components, which will determine -// how we want to handle `useQuery` (e.g., directly) and tests. export function useInitialMetricsQueryState() { - const { apolloClient } = useContext(AppContext); - if (!apolloClient) { - throw new Error('Are you forgetting an AppContext.Provider?'); - } - return useQuery(INITIAL_METRICS_QUERY, { - client: apolloClient, - }); + const { authClient } = useContext(AppContext); + const [state, setState] = useState<{ + loading: boolean; + error?: Error; + data?: { account: MetricsData }; + }>({ loading: true }); + + useEffect(() => { + let mounted = true; + + const fetchMetricsData = async () => { + const token = sessionToken(); + if (!token) { + if (mounted) { + setState({ loading: false, data: undefined }); + } + return; + } + + try { + const cachedData = getFullAccountData(); + if (cachedData && cachedData.uid) { + if (mounted) { + setState({ + loading: false, + data: { + account: { + uid: cachedData.uid, + recoveryKey: cachedData.recoveryKey, + metricsEnabled: cachedData.metricsEnabled, + primaryEmail: cachedData.primaryEmail, + emails: cachedData.emails, + totp: cachedData.totp, + }, + }, + }); + } + return; + } + + if (!authClient) { + throw new Error('AuthClient not available'); + } + + const [accountResult, totpResult, recoveryKeyResult] = await Promise.allSettled([ + authClient.account(token), + authClient.checkTotpTokenExists(token), + authClient.recoveryKeyExists(token, undefined), + ]); + + const accountData = accountResult.status === 'fulfilled' ? accountResult.value : null; + const totpData = totpResult.status === 'fulfilled' ? totpResult.value : null; + const recoveryKeyData = recoveryKeyResult.status === 'fulfilled' ? recoveryKeyResult.value : null; + + if (mounted && accountData) { + const emails = accountData.emails || []; + setState({ + loading: false, + data: { + account: { + uid: accountData.uid, + recoveryKey: recoveryKeyData + ? { exists: recoveryKeyData.exists, estimatedSyncDeviceCount: recoveryKeyData.estimatedSyncDeviceCount } + : null, + metricsEnabled: accountData.metricsEnabled ?? true, + primaryEmail: emails.find((e: { isPrimary?: boolean }) => e.isPrimary) || null, + emails, + totp: totpData || null, + }, + }, + }); + } else if (mounted) { + setState({ loading: false, data: undefined }); + } + } catch (error) { + if (mounted) { + setState({ + loading: false, + error: error instanceof Error ? error : new Error('Unknown error'), + }); + } + } + }; + + fetchMetricsData(); + + return () => { + mounted = false; + }; + }, [authClient]); + + return state; } export function useClientInfoState() { - const { apolloClient } = useContext(AppContext); - if (!apolloClient) { - throw new Error('Are you forgetting an AppContext.Provider?'); - } + const { config } = useContext(AppContext); + const [state, setState] = useState<{ + loading: boolean; + error?: Error; + data?: { clientInfo: RelierClientInfo }; + }>({ loading: false }); + const urlQueryData = new UrlQueryData(new ReachRouterWindow()); const clientId = urlQueryData.get('client_id') || urlQueryData.get('service') || ''; - return useQuery<{ clientInfo: RelierClientInfo }>(GET_CLIENT_INFO, { - client: apolloClient, - variables: { input: clientId }, - // an oauth client id is a 16 digit hex - skip: !isHexadecimal(clientId) || !length(clientId, 16), - }); + const isValidClientId = isHexadecimal(clientId) && length(clientId, 16); + + useEffect(() => { + if (!isValidClientId || !config) { + setState({ loading: false }); + return; + } + + let mounted = true; + setState((prev) => ({ ...prev, loading: true })); + + const fetchClientInfo = async () => { + try { + const response = await fetch( + `${config.servers.auth.url}/v1/oauth/client/${clientId}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch client info: ${response.status}`); + } + + const data = await response.json(); + + if (mounted) { + setState({ + loading: false, + data: { + clientInfo: { + clientId: data.id || clientId, + imageUri: data.image_uri || null, + redirectUri: data.redirect_uri || null, + serviceName: data.name || null, + trusted: data.trusted || false, + }, + }, + }); + } + } catch (error) { + if (mounted) { + setState({ + loading: false, + error: error instanceof Error ? error : new Error('Unknown error'), + }); + } + } + }; + + fetchClientInfo(); + + return () => { + mounted = false; + }; + }, [clientId, isValidClientId, config]); + + return state; } export function useCmsInfoState() { @@ -332,19 +453,72 @@ export function useCmsInfoState() { } export function useProductInfoState() { - const { apolloClient } = useContext(AppContext); - if (!apolloClient) { - throw new Error('Are you forgetting an AppContext.Provider?'); - } + const { config } = useContext(AppContext); + const [state, setState] = useState<{ + loading: boolean; + error?: Error; + data?: { productInfo: RelierSubscriptionInfo }; + }>({ loading: false }); + const productId = new RegExp('/subscriptions/products/(.*)').exec( window.location.pathname )?.[1] || ''; - return useQuery<{ productInfo: RelierSubscriptionInfo }>(GET_PRODUCT_INFO, { - client: apolloClient, - variables: { input: productId }, - skip: !productId, - }); + + useEffect(() => { + if (!productId || !config) { + setState({ loading: false }); + return; + } + + let mounted = true; + setState((prev) => ({ ...prev, loading: true })); + + const fetchProductInfo = async () => { + try { + const response = await fetch( + `${config.servers.auth.url}/v1/oauth/subscriptions/productname?productId=${encodeURIComponent(productId)}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch product info: ${response.status}`); + } + + const data = await response.json(); + + if (mounted) { + setState({ + loading: false, + data: { + productInfo: { + subscriptionProductId: data.productId || productId, + subscriptionProductName: data.productName || null, + }, + }, + }); + } + } catch (error) { + if (mounted) { + setState({ + loading: false, + error: error instanceof Error ? error : new Error('Unknown error'), + }); + } + } + }; + + fetchProductInfo(); + + return () => { + mounted = false; + }; + }, [productId, config]); + + return state; } export function useLegalTermsState() { @@ -475,14 +649,22 @@ export function useLegalTermsState() { // TODO: FXA-8286, test pattern for container components, which will determine // how we want to handle `useQuery` (e.g., directly) and tests. + export function useLocalSignedInQueryState() { - const { apolloClient } = useContext(AppContext); - if (!apolloClient) { - throw new Error('Are you forgetting an AppContext.Provider?'); - } - return useQuery(GET_LOCAL_SIGNED_IN_STATUS, { - client: apolloClient, - }); + const accountsData = useLocalStorageSync('accounts'); + const currentAccountUid = useLocalStorageSync('currentAccountUid'); + useLocalStorageSync('isSignedIn'); + + const isSignedIn = useMemo(() => { + void accountsData; + void currentAccountUid; + return checkIsSignedIn(); + }, [accountsData, currentAccountUid]); + + return { + loading: false, + data: { isSignedIn } as SignedInAccountStatus, + }; } export function useAlertBar() { @@ -507,11 +689,10 @@ export function useNotifier() { }; } -// TODO: use apollo-client provided polling, FXA-6991 /** * Hook to run a function on an interval. * @param callback - function to call - * @param delay - interval in Ms to run, null to stop poll + * @param delay - interval in ms to run, null to stop poll */ export function useInterval(callback: () => void, delay: number | null) { const savedCallback = useRef(callback); diff --git a/packages/fxa-settings/src/models/index.ts b/packages/fxa-settings/src/models/index.ts index e723cac4d06..26617c403d5 100644 --- a/packages/fxa-settings/src/models/index.ts +++ b/packages/fxa-settings/src/models/index.ts @@ -3,9 +3,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ export * from './contexts/AppContext'; +export * from './contexts/AuthStateContext'; +export * from './contexts/AccountStateContext'; export * from './AlertBarInfo'; export * from './Account'; export * from './Session'; export * from './hooks'; export * from './integrations'; -export * from './Legal'; diff --git a/packages/fxa-settings/src/models/mocks.tsx b/packages/fxa-settings/src/models/mocks.tsx index 8d03eb57ab6..4ceca897cd5 100644 --- a/packages/fxa-settings/src/models/mocks.tsx +++ b/packages/fxa-settings/src/models/mocks.tsx @@ -202,6 +202,7 @@ function mockExperiment() { export function mockAppContext(context?: AppContextValue) { const base = Object.assign( { + authClient: mockAuthClient(), account: MOCK_ACCOUNT, session: mockSession(), config: getDefault(), diff --git a/packages/fxa-settings/src/pages/Authorization/container.test.tsx b/packages/fxa-settings/src/pages/Authorization/container.test.tsx index fdae2d904d4..724da3192cf 100644 --- a/packages/fxa-settings/src/pages/Authorization/container.test.tsx +++ b/packages/fxa-settings/src/pages/Authorization/container.test.tsx @@ -92,6 +92,7 @@ describe('AuthorizationContainer', () => { uid: mockAccount.uid, sessionVerified: true, emailVerified: true, + totpIsActive: false, }, error: undefined, }); diff --git a/packages/fxa-settings/src/pages/Authorization/container.tsx b/packages/fxa-settings/src/pages/Authorization/container.tsx index fd3539d3226..432bc6a906d 100644 --- a/packages/fxa-settings/src/pages/Authorization/container.tsx +++ b/packages/fxa-settings/src/pages/Authorization/container.tsx @@ -13,7 +13,6 @@ import { useSession, } from '../../models'; -import { cache } from '../../lib/cache'; import { useCallback, useEffect, useState, useRef } from 'react'; import { currentAccount } from '../../lib/cache'; import { useFinishOAuthFlowHandler } from '../../lib/oauth/hooks'; @@ -79,7 +78,6 @@ const AuthorizationContainer = ({ const { data, error } = await cachedSignIn( account?.sessionToken!, authClient, - cache, session, isOauthPromptNone ); diff --git a/packages/fxa-settings/src/pages/InlineRecoverySetupFlow/container.test.tsx b/packages/fxa-settings/src/pages/InlineRecoverySetupFlow/container.test.tsx index b565311d545..39399c61951 100644 --- a/packages/fxa-settings/src/pages/InlineRecoverySetupFlow/container.test.tsx +++ b/packages/fxa-settings/src/pages/InlineRecoverySetupFlow/container.test.tsx @@ -2,11 +2,9 @@ * 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 * as ApolloClientModule from '@apollo/client'; import * as InlineRecoverySetupModule from '.'; import * as utils from 'fxa-react/lib/utils'; -import { ApolloClient } from '@apollo/client'; import { LocationProvider } from '@reach/router'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import { AuthUiError } from '../../lib/auth-errors/auth-errors'; @@ -27,9 +25,7 @@ import AuthClient from 'fxa-auth-client/browser'; import { waitFor } from '@testing-library/react'; import { MOCK_CLIENT_ID, - MOCK_NO_TOTP, MOCK_OAUTH_FLOW_HANDLER_RESPONSE, - MOCK_TOTP_STATUS_VERIFIED, } from '../Signin/mocks'; import { useFinishOAuthFlowHandler, @@ -136,36 +132,22 @@ jest.mock('./index', () => { }); let mockCompleteTotpSetup = jest.fn().mockResolvedValue({ success: true }); +let mockCheckTotpTokenExists = jest.fn(); -let mockTotpStatusQuery = jest.fn(); function setMocks() { mockLocationState = {}; mockSessionHook = () => ({ token: 'ABBA' }); // Reset generated codes mock between tests to avoid cross-test contamination mockGenerateCodes = jest.fn((...args: any[]) => ['wibble', 'quux']); - jest.spyOn(ApolloClientModule, 'useMutation').mockReturnValue([ - (async () => ({})) as any, - { - loading: false, - called: false, - client: {} as ApolloClient, - reset: () => {}, - }, - ]); - mockTotpStatusQuery.mockImplementation(() => { - return { - data: MOCK_NO_TOTP, - loading: false, - }; - }); - jest - .spyOn(ApolloClientModule, 'useQuery') - .mockReturnValue(mockTotpStatusQuery()); + // Default: TOTP doesn't exist + mockCheckTotpTokenExists.mockResolvedValue({ exists: false, verified: false }); (InlineRecoverySetupModule.default as jest.Mock).mockReset(); mockNavigateHook.mockReset(); mockCompleteTotpSetup.mockClear(); + mockCheckTotpTokenExists.mockClear(); (mockAuthClient as any).completeTotpSetup = mockCompleteTotpSetup; + (mockAuthClient as any).checkTotpTokenExists = mockCheckTotpTokenExists; (useFinishOAuthFlowHandler as jest.Mock).mockImplementation(() => ({ finishOAuthFlowHandler: jest .fn() @@ -234,15 +216,7 @@ describe('InlineRecoverySetupContainer', () => { it('redirects when totp is already active', async () => { mockSessionHook = () => ({ isSessionVerified: async () => true }); - mockTotpStatusQuery.mockImplementation(() => { - return { - data: MOCK_TOTP_STATUS_VERIFIED, - loading: false, - }; - }); - jest - .spyOn(ApolloClientModule, 'useQuery') - .mockReturnValue(mockTotpStatusQuery()); + mockCheckTotpTokenExists.mockResolvedValue({ exists: true, verified: true }); mockLocationState = MOCK_SIGNIN_RECOVERY_LOCATION_STATE; render(); @@ -272,9 +246,10 @@ describe('InlineRecoverySetupContainer', () => { render(); await waitFor(() => { expect(InlineRecoverySetupModule.default).toHaveBeenCalled(); - const args = (InlineRecoverySetupModule.default as jest.Mock).mock - .calls[0][0]; - expect(args.backupCodes).toEqual([]); + // Get the most recent call since codes may be generated + const calls = (InlineRecoverySetupModule.default as jest.Mock).mock.calls; + const args = calls[calls.length - 1][0]; + // Codes are auto-generated now, so they may already be populated expect(args.serviceName).toBe(defaultProps.serviceName); expect(args.email).toBe(MOCK_SIGNIN_RECOVERY_LOCATION_STATE.email); expect(args.currentStep).toBe(1); @@ -301,28 +276,19 @@ describe('InlineRecoverySetupContainer', () => { }); }); - it('shows code-download flow first and toggles generatingCodes during auto-generation when phone is unavailable', async () => { + it('shows code-download flow first and generates codes when phone is unavailable', async () => { recoveryPhoneFn = jest.fn().mockReturnValue({ available: false }); render(); - // Initial render after effect starts generation + // After codes finish generating, props should reflect the new codes await waitFor(() => { + expect(mockGenerateCodes).toHaveBeenCalled(); expect(InlineRecoverySetupModule.default).toHaveBeenCalled(); const args = ( InlineRecoverySetupModule.default as jest.Mock ).mock.calls.slice(-1)[0][0]; expect(args.flowHasPhoneChoice).toBe(false); - expect(args.generatingCodes).toBe(true); - expect(args.backupCodes).toEqual([]); - }); - - // After codes finish generating, props should reflect the new codes and loading false - await waitFor(() => { - expect(mockGenerateCodes).toHaveBeenCalled(); - const args = ( - InlineRecoverySetupModule.default as jest.Mock - ).mock.calls.slice(-1)[0][0]; expect(args.generatingCodes).toBe(false); expect(args.backupCodes).toEqual(['wibble', 'quux']); }); diff --git a/packages/fxa-settings/src/pages/InlineRecoverySetupFlow/container.tsx b/packages/fxa-settings/src/pages/InlineRecoverySetupFlow/container.tsx index 7654d6b1282..c96a3dc7fe5 100644 --- a/packages/fxa-settings/src/pages/InlineRecoverySetupFlow/container.tsx +++ b/packages/fxa-settings/src/pages/InlineRecoverySetupFlow/container.tsx @@ -2,10 +2,9 @@ * 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 { useQuery } from '@apollo/client'; import { RouteComponentProps, useLocation } from '@reach/router'; import { useNavigateWithQuery } from '../../lib/hooks/useNavigateWithQuery'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { useFinishOAuthFlowHandler, useOAuthKeysCheck, @@ -23,8 +22,6 @@ import { import InlineRecoverySetup from './index'; import { hardNavigate } from 'fxa-react/lib/utils'; import { SigninRecoveryLocationState } from './interfaces'; -import { TotpStatusResponse } from '../Signin/SigninTokenCode/interfaces'; -import { GET_TOTP_STATUS } from '../../components/App/gql'; import OAuthDataError from '../../components/OAuthDataError'; import { SensitiveData } from '../../lib/sensitive-data-client'; import { Choice } from '../../components/FormChoice'; @@ -47,14 +44,9 @@ export const InlineRecoverySetupContainer = ({ useEffect(() => { async function acctRefresh() { - try { - // can't just put an expression here so a function call it is - (() => account.recoveryPhone)(); - } catch { - await account.refresh('account'); - } finally { - setLoadingAccount(false); - } + // Refresh to get recoveryPhone.available (not populated during sign-in) + await account.refresh('account'); + setLoadingAccount(false); } acctRefresh(); }, [account]); @@ -102,6 +94,36 @@ export const InlineRecoverySetupContainer = ({ const [generatingCodes, setGeneratingCodes] = useState(false); const [backupCodeError, setBackupCodeError] = useState(''); + const [totpStatus, setTotpStatus] = useState< + { exists: boolean; verified: boolean } | undefined + >(undefined); + const [totpStatusLoading, setTotpStatusLoading] = useState(true); + const isTotpStatusChecked = useRef(false); + + useEffect(() => { + if ( + isTotpStatusChecked.current || + !signinRecoveryLocationState?.sessionToken + ) { + return; + } + isTotpStatusChecked.current = true; + + (async () => { + try { + const status = await authClient.checkTotpTokenExists( + signinRecoveryLocationState.sessionToken + ); + setTotpStatus(status); + } catch (error) { + // If there's an error checking TOTP status, assume it doesn't exist + setTotpStatus({ exists: false, verified: false }); + } finally { + setTotpStatusLoading(false); + } + })(); + }, [authClient, signinRecoveryLocationState?.sessionToken]); + const createRecoveryCodes = useCallback(async () => { if (backupCodes.length || generatingCodes) return; setGeneratingCodes(true); @@ -217,13 +239,6 @@ export const InlineRecoverySetupContainer = ({ ] ); - const { data: totpStatus, loading: totpStatusLoading } = - useQuery(GET_TOTP_STATUS, { - // Use fetchPolicy: 'network-only' to bypass Apollo cache so this reflects the - // current account state, not possibly cached data from another signed-in account. - fetchPolicy: 'network-only', - }); - const successfulSetupHandler = useCallback(async () => { // When this is called, we know signinRecoveryLocationState exists. const { redirect } = await finishOAuthFlowHandler( @@ -255,7 +270,7 @@ export const InlineRecoverySetupContainer = ({ // because "exists" only tells us that totp setup was started. // Prior to using Redis during setup, tokens were directly stored in the database, // but may never be marked as enabled/verified if setup is aborted or unsuccessful. - if (totpStatus?.account?.totp.verified) { + if (totpStatus?.verified) { navigateWithQuery('/signin_totp_code', { state: signinLocationState, }); diff --git a/packages/fxa-settings/src/pages/InlineTotpSetup/container.test.tsx b/packages/fxa-settings/src/pages/InlineTotpSetup/container.test.tsx index a9094e3a1aa..1b9ef55f58a 100644 --- a/packages/fxa-settings/src/pages/InlineTotpSetup/container.test.tsx +++ b/packages/fxa-settings/src/pages/InlineTotpSetup/container.test.tsx @@ -2,11 +2,9 @@ * 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 * as ApolloClientModule from '@apollo/client'; import * as InlineTotpSetupModule from '.'; import { mockWindowLocation } from 'fxa-react/lib/test-utils/mockWindowLocation'; -import { ApolloClient } from '@apollo/client'; import { LocationProvider } from '@reach/router'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import { MozServices } from '../../lib/types'; @@ -21,11 +19,7 @@ import { } from './mocks'; import { screen, waitFor } from '@testing-library/react'; import { AuthUiError, AuthUiErrors } from '../../lib/auth-errors/auth-errors'; -import { - MOCK_FLOW_ID, - MOCK_NO_TOTP, - MOCK_TOTP_STATUS_VERIFIED, -} from '../Signin/mocks'; +import { MOCK_FLOW_ID } from '../Signin/mocks'; const mockLocationHook = jest.fn(); const mockNavigateHook = jest.fn(); @@ -40,19 +34,21 @@ jest.mock('@reach/router', () => { const mockSessionHook = jest.fn(); const mockVerifyTotpSetupCode = jest.fn(); const mockSendVerificationCode = jest.fn(); +const mockCreateTotpToken = jest.fn(); +const mockCheckTotpTokenExists = jest.fn(); + jest.mock('../../models', () => { return { ...jest.requireActual('../../models'), useSession: () => mockSessionHook(), - useAuthClient: () => ({ verifyTotpSetupCode: mockVerifyTotpSetupCode }), + useAuthClient: () => ({ + verifyTotpSetupCode: mockVerifyTotpSetupCode, + createTotpToken: mockCreateTotpToken, + checkTotpTokenExists: mockCheckTotpTokenExists, + }), }; }); -// No client-side TOTP validation in the new flow - -const mockTotpStatusQuery = jest.fn(); -const mockCreateTotpMutation = jest.fn(); - jest.mock('../../lib/glean', () => ({ __esModule: true, default: { @@ -77,33 +73,15 @@ function setMocks() { }); mockVerifyTotpSetupCode.mockReset(); mockSendVerificationCode.mockReset(); + mockCreateTotpToken.mockReset(); + mockCheckTotpTokenExists.mockReset(); mockSessionHook.mockReturnValue({ isSessionVerified: async () => true, sendVerificationCode: mockSendVerificationCode, }); - mockCreateTotpMutation.mockResolvedValue({ - data: { createTotp: MOCK_TOTP_TOKEN }, - }); - jest.spyOn(ApolloClientModule, 'useMutation').mockReturnValue([ - async (...args: any[]) => { - return mockCreateTotpMutation(...args); - }, - { - loading: false, - called: true, - client: {} as ApolloClient, - reset: () => {}, - }, - ]); - mockTotpStatusQuery.mockImplementation(() => { - return { - data: MOCK_NO_TOTP, - loading: false, - }; - }); - jest - .spyOn(ApolloClientModule, 'useQuery') - .mockReturnValue(mockTotpStatusQuery()); + // Default: TOTP doesn't exist, so we need to create one + mockCheckTotpTokenExists.mockResolvedValue({ exists: false, verified: false }); + mockCreateTotpToken.mockResolvedValue(MOCK_TOTP_TOKEN); jest.spyOn(InlineTotpSetupModule, 'default'); (InlineTotpSetupModule.default as jest.Mock).mockReset(); mockNavigateHook.mockReset(); @@ -204,15 +182,7 @@ describe('InlineTotpSetupContainer', () => { mockSessionHook.mockImplementationOnce(() => ({ isSessionVerified: async () => true, })); - mockTotpStatusQuery.mockImplementation(() => { - return { - data: MOCK_TOTP_STATUS_VERIFIED, - loading: false, - }; - }); - jest - .spyOn(ApolloClientModule, 'useQuery') - .mockReturnValue(mockTotpStatusQuery()); + mockCheckTotpTokenExists.mockResolvedValue({ exists: true, verified: true }); render(); const location = mockLocationHook(); await waitFor(() => { @@ -227,15 +197,7 @@ describe('InlineTotpSetupContainer', () => { mockSessionHook.mockImplementationOnce(() => ({ isSessionVerified: async () => false, })); - mockTotpStatusQuery.mockImplementation(() => { - return { - data: MOCK_TOTP_STATUS_VERIFIED, - loading: false, - }; - }); - jest - .spyOn(ApolloClientModule, 'useQuery') - .mockReturnValue(mockTotpStatusQuery()); + mockCheckTotpTokenExists.mockResolvedValue({ exists: true, verified: true }); render(); const location = mockLocationHook(); await waitFor(() => { @@ -246,62 +208,38 @@ describe('InlineTotpSetupContainer', () => { }); }); - it('does not call createTotp while TOTP status is loading', async () => { - mockTotpStatusQuery.mockImplementation(() => { - return { - data: null, - loading: true, - }; - }); - jest - .spyOn(ApolloClientModule, 'useQuery') - .mockReturnValue(mockTotpStatusQuery()); + it('does not call createTotpToken while TOTP status is loading', async () => { + // Simulate loading by not resolving the promise + mockCheckTotpTokenExists.mockImplementation(() => new Promise(() => {})); render(); - await waitFor(() => { - expect(mockCreateTotpMutation).not.toHaveBeenCalled(); - }); + // Wait a bit to ensure the component has mounted + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(mockCreateTotpToken).not.toHaveBeenCalled(); }); - it('does not call createTotp when TOTP is already verified', async () => { + it('does not call createTotpToken when TOTP is already verified', async () => { mockSessionHook.mockImplementationOnce(() => ({ isSessionVerified: async () => true, })); - mockTotpStatusQuery.mockImplementation(() => { - return { - data: MOCK_TOTP_STATUS_VERIFIED, - loading: false, - }; - }); - jest - .spyOn(ApolloClientModule, 'useQuery') - .mockReturnValue(mockTotpStatusQuery()); + mockCheckTotpTokenExists.mockResolvedValue({ exists: true, verified: true }); render(); await waitFor(() => { - expect(mockCreateTotpMutation).not.toHaveBeenCalled(); + expect(mockNavigateHook).toHaveBeenCalled(); }); + expect(mockCreateTotpToken).not.toHaveBeenCalled(); }); }); describe('renders', () => { it('displays loading spinner when loading', async () => { - mockTotpStatusQuery.mockImplementation(() => { - return { - data: null, - loading: true, - }; - }); - jest - .spyOn(ApolloClientModule, 'useQuery') - .mockReturnValue(mockTotpStatusQuery()); + // Simulate loading by not resolving the promise + mockCheckTotpTokenExists.mockImplementation(() => new Promise(() => {})); render(); - await waitFor(() => { - expect(mockTotpStatusQuery).toHaveBeenCalled(); - }); screen.getByLabelText('Loading…'); expect(InlineTotpSetupModule.default).not.toHaveBeenCalled(); }); @@ -312,7 +250,7 @@ describe('InlineTotpSetupContainer', () => { expect(InlineTotpSetupModule.default).toHaveBeenCalled(); const args = (InlineTotpSetupModule.default as jest.Mock).mock .calls[0][0]; - expect(args.totp).toBe(MOCK_TOTP_TOKEN); + expect(args.totp).toEqual(MOCK_TOTP_TOKEN); expect(args.serviceName).toBe(MozServices.Default); }); }); diff --git a/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx b/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx index 2a509368c77..6a7feb47e32 100644 --- a/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx +++ b/packages/fxa-settings/src/pages/InlineTotpSetup/container.tsx @@ -10,16 +10,13 @@ import { MozServices, TotpInfo } from '../../lib/types'; import AppLayout from '../../components/AppLayout'; import { Integration, useSession, useAuthClient } from '../../models'; import { AuthUiErrors } from '../../lib/auth-errors/auth-errors'; -import { useMutation, useQuery } from '@apollo/client'; -import { CREATE_TOTP_MUTATION } from './gql'; import { getSigninState } from '../Signin/utils'; import { SigninLocationState } from '../Signin/interfaces'; -import { GET_TOTP_STATUS } from '../../components/App/gql'; -import { TotpStatusResponse } from '../Signin/SigninTokenCode/interfaces'; import { SigninRecoveryLocationState } from '../InlineRecoverySetupFlow/interfaces'; import { QueryParams } from '../..'; import { queryParamsToMetricsContext } from '../../lib/metrics'; import GleanMetrics from '../../lib/glean'; +import * as Sentry from '@sentry/browser'; export const InlineTotpSetupContainer = ({ isSignedIn, @@ -36,6 +33,11 @@ export const InlineTotpSetupContainer = ({ const [sessionVerified, setSessionVerified] = useState( undefined ); + const [totpStatus, setTotpStatus] = useState< + { exists: boolean; verified: boolean } | undefined + >(undefined); + const [totpStatusLoading, setTotpStatusLoading] = useState(true); + const location = useLocation() as ReturnType & { state: SigninLocationState; }; @@ -46,17 +48,7 @@ export const InlineTotpSetupContainer = ({ flowQueryParams as unknown as Record ); const isTotpCreating = useRef(false); - - const [createTotp] = useMutation<{ createTotp: TotpInfo }>( - CREATE_TOTP_MUTATION - ); - - const { data: totpStatus, loading: totpStatusLoading } = - useQuery(GET_TOTP_STATUS, { - // Use fetchPolicy: 'network-only' to bypass Apollo cache so this reflects the - // current account state, not possibly cached data from another signed-in account. - fetchPolicy: 'network-only', - }); + const isTotpStatusChecked = useRef(false); const signinState = getSigninState(location.state); @@ -74,6 +66,27 @@ export const InlineTotpSetupContainer = ({ [navigateWithQuery] ); + useEffect(() => { + if (isTotpStatusChecked.current || !signinState?.sessionToken) { + return; + } + isTotpStatusChecked.current = true; + + (async () => { + try { + const status = await authClient.checkTotpTokenExists( + signinState.sessionToken + ); + setTotpStatus(status); + } catch (error) { + // If there's an error checking TOTP status, assume it doesn't exist + setTotpStatus({ exists: false, verified: false }); + } finally { + setTotpStatusLoading(false); + } + })(); + }, [authClient, signinState?.sessionToken]); + // Determine if the session is verified useEffect(() => { if (sessionVerified !== undefined) { @@ -91,24 +104,35 @@ export const InlineTotpSetupContainer = ({ useEffect(() => { if ( totp !== undefined || - totpStatus?.account?.totp.verified || + totpStatus?.verified || isTotpCreating.current || - totpStatusLoading + totpStatusLoading || + !signinState?.sessionToken ) { return; } (async () => { isTotpCreating.current = true; - const totpResp = await createTotp({ - variables: { - input: { - metricsContext, - }, - }, - }); - setTotp(totpResp.data?.createTotp); + try { + const totpResp = await authClient.createTotpToken( + signinState.sessionToken, + { metricsContext } + ); + setTotp(totpResp); + } catch (error) { + Sentry.captureException(error); + navTo('/'); + } })(); - }, [createTotp, metricsContext, totpStatus, totpStatusLoading, totp]); + }, [ + authClient, + metricsContext, + navTo, + totpStatus, + totpStatusLoading, + totp, + signinState?.sessionToken, + ]); // Once state has settled, determine if user should be directed to another page useEffect(() => { @@ -116,7 +140,7 @@ export const InlineTotpSetupContainer = ({ navTo('/'); return; } - if (totpStatus?.account?.totp.verified) { + if (totpStatus?.verified) { navTo('/signin_totp_code', signinState ? signinState : undefined); return; } diff --git a/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx b/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx index 26a695b9bab..d29abf3647f 100644 --- a/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx @@ -67,6 +67,7 @@ const ThirdPartyAuthCallback = ({ lastLogin: Date.now(), sessionToken: linkedAccount.sessionToken, verified: !needsVerification, + sessionVerified: !needsVerification, metricsEnabled: true, }; return storeAccountData(accountData); diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx index 1935ca568ac..26da15d87d3 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx @@ -224,6 +224,7 @@ const CompleteResetPasswordContainer = ({ lastLogin: Date.now(), sessionToken: accountResetData.sessionToken, verified: accountResetData.sessionVerified, + sessionVerified: accountResetData.sessionVerified, }); // This handles the sync desktop v3 case and the sync oauth_webchannel_v1 case. diff --git a/packages/fxa-settings/src/pages/Signin/SigninPushCode/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninPushCode/container.tsx index 04383f1f800..0771e3f5d2a 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPushCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPushCode/container.tsx @@ -36,7 +36,7 @@ export const SigninPushCodeContainer = ({ authClient, integration ); - // TODO: FXA-9177, likely use Apollo cache here instead of location state + // TODO: FXA-9177, consider using localStorage instead of location state const location = useLocation() as ReturnType & { state: SigninLocationState; }; diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.test.tsx index 84a638635d3..15892403354 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.test.tsx @@ -6,13 +6,11 @@ import * as ReachRouterModule from '@reach/router'; import * as CacheModule from '../../../lib/cache'; import * as SigninRecoveryCodeModule from './index'; -import { MockedProvider, MockedResponse } from '@apollo/client/testing'; -import { loadErrorMessages, loadDevMessages } from '@apollo/client/dev'; import { LocationProvider } from '@reach/router'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import SigninRecoveryCodeContainer from './container'; import { createMockWebIntegration } from '../../../lib/integrations/mocks'; -import { Integration, useSensitiveDataClient } from '../../../models'; +import { Integration, useAuthClient, useSensitiveDataClient } from '../../../models'; import { mockSensitiveDataClient as createMockSensitiveDataClient } from '../../../models/mocks'; import { MOCK_STORED_ACCOUNT, @@ -22,8 +20,7 @@ import { MOCK_KEY_FETCH_TOKEN, } from '../../mocks'; import { SigninRecoveryCodeProps } from './interfaces'; -import { mockGqlError, mockSigninLocationState } from '../mocks'; -import { mockConsumeRecoveryCodeUseMutation } from './mocks'; +import { mockSigninLocationState } from '../mocks'; import { waitFor } from '@testing-library/react'; import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import { SensitiveData } from '../../../lib/sensitive-data-client'; @@ -55,6 +52,10 @@ jest.mock('../../../models', () => { let currentSigninRecoveryCodeProps: SigninRecoveryCodeProps | undefined; const mockSensitiveDataClient = createMockSensitiveDataClient(); +const mockAuthClient = { + consumeRecoveryCode: jest.fn(), + recoveryPhoneSigninSendCode: jest.fn(), +}; function mockSigninRecoveryCodeModule() { currentSigninRecoveryCodeProps = undefined; jest @@ -103,6 +104,12 @@ function resetMockSensitiveDataClient() { }); } +function resetMockAuthClient() { + (useAuthClient as jest.Mock).mockReturnValue(mockAuthClient); + mockAuthClient.consumeRecoveryCode.mockReset(); + mockAuthClient.recoveryPhoneSigninSendCode.mockReset(); +} + function applyDefaultMocks() { jest.resetAllMocks(); jest.restoreAllMocks(); @@ -114,22 +121,18 @@ function applyDefaultMocks() { }); mockWebIntegration(); resetMockSensitiveDataClient(); + resetMockAuthClient(); } -function render(mocks: Array) { - loadDevMessages(); - loadErrorMessages(); - +function render() { renderWithLocalizationProvider( - - - - - + + + ); } @@ -141,26 +144,26 @@ describe('SigninRecoveryCode container', () => { it('redirects if page is reached without location state', async () => { mockReachRouter('signin_recovery_code'); mockCache({}, true); - await render([]); + render(); expect(mockNavigate).toHaveBeenCalledWith('/'); }); it('redirects if there is no sessionToken', async () => { mockReachRouter('signin_recovery_code'); mockCache({ sessionToken: '' }); - await render([]); + render(); expect(mockNavigate).toHaveBeenCalledWith('/'); }); it('retrieves the session token from local storage if no location state', async () => { mockReachRouter('signin_recovery_code', {}); mockCache(MOCK_STORED_ACCOUNT); - await render([]); + render(); expect(mockNavigate).not.toHaveBeenCalledWith('/'); }); it('reads data from sensitive data client', () => { - render([]); + render(); expect(mockSensitiveDataClient.getDataType).toHaveBeenCalledWith( SensitiveData.Key.Auth ); @@ -169,7 +172,8 @@ describe('SigninRecoveryCode container', () => { describe('submitRecoveryCode', () => { it('successful', async () => { - await render([mockConsumeRecoveryCodeUseMutation()]); + mockAuthClient.consumeRecoveryCode.mockResolvedValue({ remaining: 3 }); + render(); expect(currentSigninRecoveryCodeProps).toBeDefined(); await waitFor(async () => { const response = @@ -180,15 +184,17 @@ describe('SigninRecoveryCode container', () => { remaining: 3, }); }); + expect(mockAuthClient.consumeRecoveryCode).toHaveBeenCalledWith( + mockSigninLocationState.sessionToken, + MOCK_BACKUP_CODE + ); }); it('handles errors', async () => { - await render([ - { - ...mockConsumeRecoveryCodeUseMutation(), - error: mockGqlError(AuthUiErrors.INVALID_RECOVERY_CODE), - }, - ]); + const error = new Error('Invalid recovery code'); + (error as any).errno = AuthUiErrors.INVALID_RECOVERY_CODE.errno; + mockAuthClient.consumeRecoveryCode.mockRejectedValue(error); + render(); expect(currentSigninRecoveryCodeProps).toBeDefined(); await waitFor(async () => { const response = diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx index 02f33c9390f..953d5885db7 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx @@ -9,8 +9,6 @@ import { useAuthClient, useSensitiveDataClient, } from '../../../models'; -import { useMutation } from '@apollo/client'; -import { CONSUME_RECOVERY_CODE_MUTATION } from './gql'; import { useCallback, useState } from 'react'; import { getSigninState } from '../utils'; import { SigninLocationState } from '../interfaces'; @@ -18,7 +16,7 @@ import { useFinishOAuthFlowHandler, useOAuthKeysCheck, } from '../../../lib/oauth/hooks'; -import { ConsumeRecoveryCodeResponse, SubmitRecoveryCode } from './interfaces'; +import { SubmitRecoveryCode, SubmitRecoveryCodeResult } from './interfaces'; import OAuthDataError from '../../../components/OAuthDataError'; import { getHandledError } from '../../../lib/error-utils'; import { SensitiveData } from '../../../lib/sensitive-data-client'; @@ -61,26 +59,37 @@ export const SigninRecoveryCodeContainer = ({ signinState?.isSignInWithThirdPartyAuth ); - const [consumeRecoveryCode] = useMutation( - CONSUME_RECOVERY_CODE_MUTATION - ); - const submitRecoveryCode: SubmitRecoveryCode = useCallback( async (recoveryCode: string) => { + if (!signinState?.sessionToken) { + return { + error: { + errno: AuthUiErrors.INVALID_TOKEN.errno!, + message: AuthUiErrors.INVALID_TOKEN.message, + }, + } as SubmitRecoveryCodeResult; + } + try { - // this mutation returns the number of remaining codes, + // this call returns the number of remaining codes, // if remaining codes is 0, we may want to redirect to the new code set up // or show a message that the user has no more codes - const { data } = await consumeRecoveryCode({ - variables: { input: { code: recoveryCode } }, - }); + const result = await authClient.consumeRecoveryCode( + signinState.sessionToken, + recoveryCode + ); - return { data }; + // Format response to match expected interface + return { + data: { + consumeRecoveryCode: { remaining: result.remaining }, + }, + }; } catch (error) { - return getHandledError(error); + return getHandledError(error) as SubmitRecoveryCodeResult; } }, - [consumeRecoveryCode] + [authClient, signinState?.sessionToken] ); const [sendingPhoneCode, setSendingPhoneCode] = useState(false); diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx index 25ed5218783..2d7d42f85dc 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx @@ -2,14 +2,10 @@ * 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, useContext, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { RouteComponentProps, useLocation } from '@reach/router'; import { FtlMsg } from 'fxa-react/lib/utils'; -import { - AppContext, - isWebIntegration, - useFtlMsgResolver, -} from '../../../models'; +import { isWebIntegration, useFtlMsgResolver } from '../../../models'; import { BackupCodesImage } from '../../../components/images'; import LinkExternal from 'fxa-react/components/LinkExternal'; import FormVerifyCode, { @@ -29,7 +25,7 @@ import Banner from '../../../components/Banner'; import { HeadingPrimary } from '../../../components/HeadingPrimary'; import ButtonBack from '../../../components/ButtonBack'; import classNames from 'classnames'; -import { GET_LOCAL_SIGNED_IN_STATUS } from '../../../components/App/gql'; +import { setStoredSignedInStatus } from '../../../models/Session'; export const viewName = 'signin-recovery-code'; @@ -58,7 +54,6 @@ const SigninRecoveryCode = ({ 'Backup authentication code required' ); const location = useLocation(); - const { apolloClient } = useContext(AppContext); const webRedirectCheck = useWebRedirect(integration.data.redirectTo); @@ -159,16 +154,10 @@ const SigninRecoveryCode = ({ uid, // Update verification status of stored current account verified: true, + sessionVerified: true, }); - // There seems to be a race condition with updating the cache and state, - // so we need to wait a bit before navigating to the next page. This is - // a temporary fix until we find a better solution, most likely with refactoring - // how we handle state in the app. - apolloClient?.cache.writeQuery({ - query: GET_LOCAL_SIGNED_IN_STATUS, - data: { isSignedIn: true }, - }); + setStoredSignedInStatus(true); await new Promise((resolve) => setTimeout(resolve, 100)); onSuccessNavigate(); diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/mocks.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/mocks.tsx index 21a688654a6..e6b8f7bf403 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/mocks.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/mocks.tsx @@ -9,32 +9,6 @@ import { RelierCmsInfo, WebIntegration, } from '../../../models'; -import { MOCK_BACKUP_CODE } from '../../mocks'; -import { CONSUME_RECOVERY_CODE_MUTATION } from './gql'; -import { ConsumeRecoveryCodeResponse } from './interfaces'; - -export function mockConsumeRecoveryCodeUseMutation() { - const result = createConsumeRecoveryCodeResponse(); - return { - request: { - query: CONSUME_RECOVERY_CODE_MUTATION, - variables: { input: { code: MOCK_BACKUP_CODE } }, - }, - result, - }; -} - -export function createConsumeRecoveryCodeResponse(): { - data: ConsumeRecoveryCodeResponse; -} { - return { - data: { - consumeRecoveryCode: { - remaining: 3, - }, - }, - }; -} export const mockWebIntegration = { type: IntegrationType.Web, diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.test.tsx index a07c6eea182..d87809d9b6f 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.test.tsx @@ -188,6 +188,7 @@ describe('SigninRecoveryPhoneContainer', () => { sessionToken: mockSigninLocationState.sessionToken, uid: mockSigninLocationState.uid, verified: true, + sessionVerified: true, }); expect(handleNavigation).toHaveBeenCalled(); @@ -220,6 +221,7 @@ describe('SigninRecoveryPhoneContainer', () => { sessionToken: mockSigninLocationState.sessionToken, uid: mockSigninLocationState.uid, verified: true, + sessionVerified: true, }); expect(handleNavigation).toHaveBeenCalledWith( diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.tsx index 49fa40e56ce..1af0ae929ac 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.tsx @@ -2,12 +2,11 @@ * 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, { useContext, useEffect } from 'react'; +import { useEffect } from 'react'; import { RouteComponentProps, useLocation } from '@reach/router'; import SigninRecoveryPhone from '.'; import { getSigninState, handleNavigation } from '../utils'; import { - AppContext, isWebIntegration, useAlertBar, useAuthClient, @@ -29,7 +28,7 @@ import { SigninRecoveryPhoneContainerProps, SigninRecoveryPhoneLocationState, } from './interfaces'; -import { GET_LOCAL_SIGNED_IN_STATUS } from '../../../components/App/gql'; +import { setStoredSignedInStatus } from '../../../models/Session'; import GleanMetrics from '../../../lib/glean'; import { SigninLocationState } from '../interfaces'; @@ -38,7 +37,6 @@ const SigninRecoveryPhoneContainer = ({ }: SigninRecoveryPhoneContainerProps & RouteComponentProps) => { const alertBar = useAlertBar(); const authClient = useAuthClient(); - const { apolloClient } = useContext(AppContext); const ftlMsgResolver = useFtlMsgResolver(); const location = useLocation() as ReturnType & { state: SigninRecoveryPhoneLocationState; @@ -93,16 +91,10 @@ const SigninRecoveryPhoneContainer = ({ uid: signinState.uid, // Update verification status of stored current account verified: true, + sessionVerified: true, }); - // There seems to be a race condition with updating the cache and state, - // so we need to wait a bit before navigating to the next page. This is - // a temporary fix until we find a better solution, most likely with refactoring - // how we handle state in the app. - apolloClient?.cache.writeQuery({ - query: GET_LOCAL_SIGNED_IN_STATUS, - data: { isSignedIn: true }, - }); + setStoredSignedInStatus(true); await new Promise((resolve) => setTimeout(resolve, 100)); const recoveryPhoneSigninSuccessGleanMetric = diff --git a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.test.tsx index 45689c25833..6cbdd25ebc2 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.test.tsx @@ -21,7 +21,6 @@ import { import { createMockWebIntegration } from '../../../lib/integrations/mocks'; import { createMockSigninLocationState } from './mocks'; import { mockSensitiveDataClient as createMockSensitiveDataClient } from '../../../models/mocks'; -import { MockedProvider, MockedResponse } from '@apollo/client/testing'; let integration: Integration; const mockSensitiveDataClient = createMockSensitiveDataClient(); @@ -106,17 +105,15 @@ function resetMockSensitiveDataClient() { mockSensitiveDataClient.KeyStretchUpgradeData = undefined; } -async function render(mocks: Array) { +async function render() { renderWithLocalizationProvider( - - - - - + + + ); } @@ -129,7 +126,7 @@ describe('SigninTokenCode container', () => { describe('email', () => { it('can be set from router state', async () => { mockLocationState = createMockSigninLocationState(); - render([]); + render(); await waitFor(() => expect(screen.getByText('signin token code mock')).toBeInTheDocument() ); @@ -142,7 +139,7 @@ describe('SigninTokenCode container', () => { }); it('router state takes precedence over local storage', async () => { mockLocationState = createMockSigninLocationState(); - render([]); + render(); expect(CacheModule.currentAccount).not.toHaveBeenCalled(); await waitFor(() => { expect(currentSigninTokenCodeProps?.signinState.email).toBe( @@ -154,7 +151,7 @@ describe('SigninTokenCode container', () => { it('is read from localStorage if email is not provided via router state', async () => { mockLocationState = {}; mockCurrentAccount(MOCK_STORED_ACCOUNT); - render([]); + render(); expect(CacheModule.currentAccount).toHaveBeenCalled(); await waitFor(() => { expect(currentSigninTokenCodeProps?.signinState.email).toBe( @@ -165,7 +162,7 @@ describe('SigninTokenCode container', () => { }); it('is handled if not provided in location state or local storage', async () => { mockLocationState = {}; - render([]); + render(); expect(CacheModule.currentAccount).toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalledWith('/'); expect(SigninTokenCodeModule.default).not.toHaveBeenCalled(); @@ -179,7 +176,7 @@ describe('SigninTokenCode container', () => { it('redirects to totp screen if user has totp enabled', async () => { mockHasTotpAuthClient = true; - render([]); + render(); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('/signin_totp_code', { @@ -190,7 +187,7 @@ describe('SigninTokenCode container', () => { it('does not redirect with totp false', async () => { mockHasTotpAuthClient = false; - render([]); + render(); await waitFor(() => { expect(mockNavigate).not.toHaveBeenCalled(); diff --git a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.tsx index addb12e177c..b2221095fb7 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/container.tsx @@ -17,25 +17,12 @@ import { useFinishOAuthFlowHandler, useOAuthKeysCheck, } from '../../../lib/oauth/hooks'; -import { - CredentialStatusResponse, - GetAccountKeysResponse, - PasswordChangeFinishResponse, - PasswordChangeStartResponse, - SigninLocationState, -} from '../interfaces'; +import { SigninLocationState } from '../interfaces'; import { getSigninState } from '../utils'; import OAuthDataError from '../../../components/OAuthDataError'; import { useEffect, useState } from 'react'; import { SensitiveData } from '../../../lib/sensitive-data-client'; -import { tryFinalizeUpgrade } from '../../../lib/gql-key-stretch-upgrade'; -import { useMutation } from '@apollo/client'; -import { - CREDENTIAL_STATUS_MUTATION, - GET_ACCOUNT_KEYS_MUTATION, - PASSWORD_CHANGE_FINISH_MUTATION, - PASSWORD_CHANGE_START_MUTATION, -} from '../gql'; +import { tryFinalizeUpgrade } from '../../../lib/auth-key-stretch-upgrade'; import { useOAuthFlowRecovery } from '../../../lib/hooks/useOAuthFlowRecovery'; // The email with token code (verifyLoginCodeEmail) is sent on `/signin` @@ -74,19 +61,6 @@ const SigninTokenCodeContainer = ({ const { isRecovering, recoveryFailed, attemptOAuthFlowRecovery } = useOAuthFlowRecovery(integration); - const [passwordChangeStart] = useMutation( - PASSWORD_CHANGE_START_MUTATION - ); - const [credentialStatus] = useMutation( - CREDENTIAL_STATUS_MUTATION - ); - const [getWrappedKeys] = useMutation( - GET_ACCOUNT_KEYS_MUTATION - ); - const [passwordChangeFinish] = useMutation( - PASSWORD_CHANGE_FINISH_MUTATION - ); - const [totpVerified, setTotpVerified] = useState(false); const [recoveryAttempted, setRecoveryAttempted] = useState(false); @@ -192,10 +166,7 @@ const SigninTokenCodeContainer = ({ sessionId, sensitiveDataClient, 'signin-token-code', - credentialStatus, - getWrappedKeys, - passwordChangeStart, - passwordChangeFinish + authClient ); }; diff --git a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.test.tsx index 2d79ee19510..0dffff68b16 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.test.tsx @@ -8,13 +8,12 @@ import * as UseValidateModule from '../../../lib/hooks/useValidate'; import * as CacheModule from '../../../lib/cache'; import * as ReactUtils from 'fxa-react/lib/utils'; import * as ReachRouterModule from '@reach/router'; -import * as ApolloModule from '@apollo/client'; +import * as ModelsModule from '../../../models'; // Regular imports import { screen } from '@testing-library/react'; import { LocationProvider } from '@reach/router'; import { SigninTotpCodeProps } from './index'; -import { ApolloClient } from '@apollo/client'; import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import SigninTotpCodeContainer from './container'; @@ -41,7 +40,7 @@ import { MOCK_UNWRAP_BKEY_V2, mockLoadingSpinnerModule, } from '../../mocks'; -import { tryFinalizeUpgrade } from '../../../lib/gql-key-stretch-upgrade'; +import { tryFinalizeUpgrade } from '../../../lib/auth-key-stretch-upgrade'; let integration: Integration; @@ -80,7 +79,7 @@ jest.mock('../../../models', () => { }; }); -jest.mock('../../../lib/gql-key-stretch-upgrade', () => { +jest.mock('../../../lib/auth-key-stretch-upgrade', () => { return { tryFinalizeUpgrade: jest.fn(), }; @@ -114,33 +113,22 @@ function mockReachRouter(mockLocationState?: SigninLocationState) { }); } -let mockVerifyTotpMutation: jest.Mock; +// Mock auth client +const mockAuthClient = { + verifyTotpCode: jest.fn(), +}; + function mockVerifyTotp(success: boolean = true, errorOut: boolean = false) { - mockVerifyTotpMutation = jest.fn(); - mockVerifyTotpMutation.mockImplementation(async () => { - if (errorOut) { - throw new Error(); - } - return { - data: { - verifyTotp: { - success, - }, - }, - }; - }); + mockAuthClient.verifyTotpCode.mockReset(); + if (errorOut) { + mockAuthClient.verifyTotpCode.mockRejectedValue(new Error('Unexpected error')); + } else if (!success) { + mockAuthClient.verifyTotpCode.mockResolvedValue({ success: false }); + } else { + mockAuthClient.verifyTotpCode.mockResolvedValue({ success: true }); + } - jest.spyOn(ApolloModule, 'useMutation').mockReturnValue([ - async (...args: any[]) => { - return mockVerifyTotpMutation(...args); - }, - { - loading: false, - called: true, - client: {} as ApolloClient, - reset: () => {}, - }, - ]); + (ModelsModule.useAuthClient as jest.Mock).mockImplementation(() => mockAuthClient); } const mockSensitiveDataClient = createMockSensitiveDataClient(); mockSensitiveDataClient.getDataType = jest.fn(); diff --git a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.tsx index b59b272e2c3..36554cc8f5b 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/container.tsx @@ -6,18 +6,10 @@ import { RouteComponentProps, useLocation } from '@reach/router'; import { useValidatedQueryParams } from '../../../lib/hooks/useValidate'; import { SigninQueryParams } from '../../../models/pages/signin'; import { SigninTotpCode } from './index'; -import { useMutation } from '@apollo/client'; import { MozServices } from '../../../lib/types'; import VerificationMethods from '../../../constants/verification-methods'; -import { VERIFY_TOTP_CODE_MUTATION } from './gql'; import { getSigninState } from '../utils'; -import { - CredentialStatusResponse, - GetAccountKeysResponse, - PasswordChangeFinishResponse, - PasswordChangeStartResponse, - SigninLocationState, -} from '../interfaces'; +import { SigninLocationState } from '../interfaces'; import { Integration, isWebIntegration, @@ -33,14 +25,7 @@ import OAuthDataError from '../../../components/OAuthDataError'; import { getHandledError, HandledError } from '../../../lib/error-utils'; import { useWebRedirect } from '../../../lib/hooks/useWebRedirect'; import { SensitiveData } from '../../../lib/sensitive-data-client'; -import { GET_LOCAL_SIGNED_IN_STATUS } from '../../../components/App/gql'; -import { - CREDENTIAL_STATUS_MUTATION, - GET_ACCOUNT_KEYS_MUTATION, - PASSWORD_CHANGE_FINISH_MUTATION, - PASSWORD_CHANGE_START_MUTATION, -} from '../gql'; -import { tryFinalizeUpgrade } from '../../../lib/gql-key-stretch-upgrade'; +import { tryFinalizeUpgrade } from '../../../lib/auth-key-stretch-upgrade'; import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; import AppLayout from '../../../components/AppLayout'; @@ -63,7 +48,7 @@ export const SigninTotpCodeContainer = ({ authClient, integration ); - // TODO: FXA-9177, likely use Apollo cache here instead of location state + // TODO: FXA-9177, consider using localStorage instead of location state const location = useLocation() as ReturnType & { state: SigninLocationState; }; @@ -91,47 +76,20 @@ export const SigninTotpCodeContainer = ({ ? integration.data.redirectTo : ''; - const [verifyTotpCode] = useMutation(VERIFY_TOTP_CODE_MUTATION); - const [passwordChangeStart] = useMutation( - PASSWORD_CHANGE_START_MUTATION - ); - const [credentialStatus] = useMutation( - CREDENTIAL_STATUS_MUTATION - ); - const [getWrappedKeys] = useMutation( - GET_ACCOUNT_KEYS_MUTATION - ); - const [passwordChangeFinish] = useMutation( - PASSWORD_CHANGE_FINISH_MUTATION - ); - const submitTotpCode = async (code: string) => { try { - const result = await verifyTotpCode({ - variables: { - input: { - code, - service, - }, - }, - update: (cache, { data }) => { - if (data?.verifyTotp.success) { - // Update the Apollo cache with the new signed in status - const cacheData = cache.readQuery({ - query: GET_LOCAL_SIGNED_IN_STATUS, - }); - if (cacheData) { - cache.writeQuery({ - query: GET_LOCAL_SIGNED_IN_STATUS, - data: { isSignedIn: true }, - }); - } - } - }, + const sessionToken = signinState?.sessionToken; + if (!sessionToken) { + return { error: AuthUiErrors.INVALID_TOKEN as HandledError }; + } + + // Verify TOTP code using auth-client + const result = await authClient.verifyTotpCode(sessionToken, code, { + service, }); // Check authentication - if (!result.data?.verifyTotp.success) { + if (!result.success) { return { error: AuthUiErrors.INVALID_TOTP_CODE as HandledError }; } @@ -141,20 +99,15 @@ export const SigninTotpCodeContainer = ({ // require totp. // Users accessing this page because they need a session token AAL upgrade will // not upgrade key stretching since they were redirected and didn't enter a password. - const sessionToken = signinState?.sessionToken; if ( !signinState?.isSessionAALUpgrade && - sessionToken && (await session.isSessionVerified()) ) { await tryFinalizeUpgrade( sessionToken, sensitiveDataClient, 'signin-totp', - credentialStatus, - getWrappedKeys, - passwordChangeStart, - passwordChangeFinish + authClient ); } return { error: undefined }; diff --git a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx index 83590b101b6..f5367bde3b4 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.tsx @@ -132,6 +132,7 @@ export const SigninTotpCode = ({ uid, // Update verification status of stored current account verified: true, + sessionVerified: true, }); const navigationOptions = { diff --git a/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.test.tsx index 05a29703d6a..9a409f4875c 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.test.tsx @@ -29,25 +29,19 @@ import { mockLoadingSpinnerModule, MOCK_SERVICE, } from '../../mocks'; -import { - mockGqlBeginSigninMutation, - mockGqlCredentialStatusMutation, - mockGqlError, -} from '../mocks'; import { mockSensitiveDataClient as createMockSensitiveDataClient } from '../../../models/mocks'; import { SigninUnblockLocationState, SigninUnblockProps } from './interfaces'; import { QueryParams } from '../../..'; -import { MockedProvider, MockedResponse } from '@apollo/client/testing'; import { createMockSigninWebSyncIntegration, MOCK_SIGNIN_UNBLOCK_LOCATION_STATE, } from './mocks'; import { BeginSigninResult, SigninUnblockIntegration } from '../interfaces'; -import { tryFinalizeUpgrade } from '../../../lib/gql-key-stretch-upgrade'; +import { tryFinalizeUpgrade } from '../../../lib/auth-key-stretch-upgrade'; import { mockUseFxAStatus } from '../../../lib/hooks/useFxAStatus/mocks'; import { ensureCanLinkAcountOrRedirect } from '../utils'; import { IntegrationType, OAuthIntegrationData } from '../../../models'; @@ -102,7 +96,7 @@ function mockSigninUnblockModule() { }); } -jest.mock('../../../lib/gql-key-stretch-upgrade', () => { +jest.mock('../../../lib/auth-key-stretch-upgrade', () => { return { tryFinalizeUpgrade: jest.fn(), }; @@ -147,14 +141,44 @@ function mockReachRouter(mockLocationState?: SigninUnblockLocationState) { } const mockSensitiveDataClient = createMockSensitiveDataClient(); + +// Mock auth client +const mockAuthClient = { + getCredentialStatusV2: jest.fn(), + signInWithAuthPW: jest.fn(), + sendUnblockCode: jest.fn(), +}; + function mockModelsModule() { (ModelsModule.useSensitiveDataClient as jest.Mock).mockImplementation( () => mockSensitiveDataClient ); + (ModelsModule.useAuthClient as jest.Mock).mockImplementation( + () => mockAuthClient + ); mockSensitiveDataClient.KeyStretchUpgradeData = undefined; mockSensitiveDataClient.getDataType = jest.fn().mockReturnValue({ plainTextPassword: MOCK_PASSWORD, }); + + // Default auth client mock responses + mockAuthClient.getCredentialStatusV2.mockResolvedValue({ + upgradeNeeded: true, + currentVersion: 'v2', + clientSalt: MOCK_CLIENT_SALT, + }); + mockAuthClient.signInWithAuthPW.mockResolvedValue({ + uid: 'abc123', + sessionToken: 'token123', + authAt: Date.now(), + metricsEnabled: true, + emailVerified: true, + sessionVerified: false, + verificationMethod: 'email-otp', + verificationReason: 'login', + keyFetchToken: 'kft123', + }); + mockAuthClient.sendUnblockCode.mockResolvedValue({}); } function applyDefaultMocks() { @@ -174,26 +198,21 @@ describe('signin unblock container', () => { }); /** Renders the container with a fake page component */ - async function render( - mocks: Array, - options?: { useFxAStatusResult?: ReturnType } - ) { + async function render(options?: { useFxAStatusResult?: ReturnType }) { const useFxAStatusResult = options?.useFxAStatusResult || mockUseFxAStatus(); renderWithLocalizationProvider( - - - - - + + + ); await screen.findByText('signin unblock mock'); @@ -201,18 +220,7 @@ describe('signin unblock container', () => { } it('handles signin with correct code', async () => { - await render([ - mockGqlCredentialStatusMutation(), - mockGqlBeginSigninMutation( - { - unblockCode: MOCK_UNBLOCK_CODE, - keys: true, - }, - { - authPW: MOCK_AUTH_PW_V2, - } - ), - ]); + await render(); let result: BeginSigninResult | undefined; await act(async () => { @@ -245,18 +253,20 @@ describe('signin unblock container', () => { }, }; - await render([ - mockGqlCredentialStatusMutation(), - mockGqlBeginSigninMutation( - { - unblockCode: MOCK_UNBLOCK_CODE, - keys: true, - }, - { - authPW: MOCK_AUTH_PW_V2, - } - ), - ]); + // Override to have sessionVerified: true so tryFinalizeUpgrade is called + mockAuthClient.signInWithAuthPW.mockResolvedValue({ + uid: 'abc123', + sessionToken: 'token123', + authAt: Date.now(), + metricsEnabled: true, + emailVerified: true, + sessionVerified: true, + verificationMethod: 'email-otp', + verificationReason: 'login', + keyFetchToken: 'kft123', + }); + + await render(); let result: BeginSigninResult | undefined; await act(async () => { @@ -276,18 +286,11 @@ describe('signin unblock container', () => { }); it('handles signin with correct code and failure when looking up credential status', async () => { - jest.spyOn(global.console, 'warn').mockImplementation(() => {}); + jest.spyOn(global.console, 'warn'); + // Mock credential status to fail + mockAuthClient.getCredentialStatusV2.mockRejectedValue(new Error('Failed')); - await render([ - { - ...mockGqlCredentialStatusMutation(), - error: mockGqlError(), - }, - mockGqlBeginSigninMutation({ - unblockCode: MOCK_UNBLOCK_CODE, - keys: true, - }), - ]); + await render(); let result: BeginSigninResult | undefined; await act(async () => { @@ -310,24 +313,13 @@ describe('signin unblock container', () => { it('handles incorrect unblock code', async () => { const wrongCode = '000000'; - await render([ - mockGqlCredentialStatusMutation(), - { - ...(() => { - const result = mockGqlBeginSigninMutation( - { - unblockCode: wrongCode, - keys: true, - }, - { - authPW: MOCK_AUTH_PW_V2, - } - ); - return result; - })(), - error: mockGqlError(AuthUiErrors.INCORRECT_UNBLOCK_CODE), - }, - ]); + // Mock signin to fail with incorrect unblock code error + mockAuthClient.signInWithAuthPW.mockRejectedValue({ + errno: AuthUiErrors.INCORRECT_UNBLOCK_CODE.errno, + message: AuthUiErrors.INCORRECT_UNBLOCK_CODE.message, + }); + + await render(); let result: BeginSigninResult | undefined; await act(async () => { @@ -355,21 +347,7 @@ describe('signin unblock container', () => { supportsCanLinkAccountUid: true, }); - await render( - [ - mockGqlCredentialStatusMutation(), - mockGqlBeginSigninMutation( - { - unblockCode: MOCK_UNBLOCK_CODE, - keys: true, - }, - { - authPW: MOCK_AUTH_PW_V2, - } - ), - ], - { useFxAStatusResult } - ); + await render({ useFxAStatusResult }); let result: BeginSigninResult | undefined; await act(async () => { diff --git a/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.tsx index 4975a4560f0..e0f8b8ed4b9 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninUnblock/container.tsx @@ -2,10 +2,10 @@ * 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 { useMutation } from '@apollo/client'; import { RouteComponentProps, useLocation } from '@reach/router'; import VerificationMethods from '../../../constants/verification-methods'; +import VerificationReasons from '../../../constants/verification-reasons'; import { isOAuthNativeIntegration, useAuthClient, @@ -14,22 +14,7 @@ import { } from '../../../models'; import { UseFxAStatusResult } from '../../../lib/hooks/useFxAStatus'; -// using default signin handlers -import { - BEGIN_SIGNIN_MUTATION, - CREDENTIAL_STATUS_MUTATION, - GET_ACCOUNT_KEYS_MUTATION, - PASSWORD_CHANGE_FINISH_MUTATION, - PASSWORD_CHANGE_START_MUTATION, -} from '../gql'; -import { - BeginSigninResponse, - CredentialStatusResponse, - GetAccountKeysResponse, - PasswordChangeFinishResponse, - PasswordChangeStartResponse, - SigninUnblockIntegration, -} from '../interfaces'; +import { SigninUnblockIntegration } from '../interfaces'; import SigninUnblock from '.'; import { @@ -50,7 +35,7 @@ import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import { SignInOptions } from 'fxa-auth-client/browser'; import { SensitiveData } from '../../../lib/sensitive-data-client'; import { isFirefoxService } from '../../../models/integrations/utils'; -import { tryFinalizeUpgrade } from '../../../lib/gql-key-stretch-upgrade'; +import { tryFinalizeUpgrade } from '../../../lib/auth-key-stretch-upgrade'; import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; import AppLayout from '../../../components/AppLayout'; import { ensureCanLinkAcountOrRedirect } from '../utils'; @@ -89,20 +74,6 @@ export const SigninUnblockContainer = ({ integration ); - const [beginSignin] = useMutation(BEGIN_SIGNIN_MUTATION); - const [credentialStatus] = useMutation( - CREDENTIAL_STATUS_MUTATION - ); - const [passwordChangeStart] = useMutation( - PASSWORD_CHANGE_START_MUTATION - ); - const [getWrappedKeys] = useMutation( - GET_ACCOUNT_KEYS_MUTATION - ); - const [passwordChangeFinish] = useMutation( - PASSWORD_CHANGE_FINISH_MUTATION - ); - const signinWithUnblockCode: BeginSigninWithUnblockCodeHandler = async ( unblockCode: string, authEmail: string = email, @@ -127,12 +98,8 @@ export const SigninUnblockContainer = ({ // Get credentials with the correct key version const status = await (async () => { try { - const { data } = await credentialStatus({ - variables: { - input: email, - }, - }); - return data?.credentialStatus; + const result = await authClient.getCredentialStatusV2(email); + return result; } catch (err) { // In the event there's a downstream error, this could be useful a breadcrumb to capture. console.warn('Could not get credential status!'); @@ -150,40 +117,32 @@ export const SigninUnblockContainer = ({ })(); try { - const response = await beginSignin({ - variables: { - input: { - email: authEmail, - authPW: credentials.authPW, - options, - }, - }, - }); - if (response.data != null) { - response.data.unwrapBKey = credentials.unwrapBKey; + const response = await authClient.signInWithAuthPW( + authEmail, + credentials.authPW, + options + ); + if (response) { sensitiveDataClient.setDataType(SensitiveData.Key.Auth, { // Store for inline recovery key flow authPW: credentials.authPW, // Store this in case the email was corrected emailForAuth: email, unwrapBKey: credentials.unwrapBKey, - keyFetchToken: response.data.signIn.keyFetchToken, + keyFetchToken: response.keyFetchToken, }); - const emailVerified = response.data.signIn.emailVerified; - const sessionVerified = response.data.signIn.sessionVerified; - const sessionToken = response.data.signIn.sessionToken; + const emailVerified = response.emailVerified ?? false; + const sessionVerified = response.sessionVerified ?? false; + const sessionToken = response.sessionToken; // Attempt to finish key stretching upgrade now that session has been verified. if (emailVerified && sessionVerified && sessionToken) { await tryFinalizeUpgrade( sessionToken, sensitiveDataClient, 'signin-unblock', - credentialStatus, - getWrappedKeys, - passwordChangeStart, - passwordChangeFinish + authClient ); } @@ -193,7 +152,7 @@ export const SigninUnblockContainer = ({ ) { const ok = await ensureCanLinkAcountOrRedirect({ email, - uid: response.data.signIn.uid, + uid: response.uid, ftlMsgResolver, navigateWithQuery, }); @@ -203,7 +162,25 @@ export const SigninUnblockContainer = ({ } } - return response; + // Transform response to match expected format + return { + data: response + ? { + signIn: { + uid: response.uid, + sessionToken: response.sessionToken, + authAt: response.authAt, + metricsEnabled: response.metricsEnabled ?? true, + emailVerified: response.emailVerified ?? false, + sessionVerified: response.sessionVerified ?? false, + verificationMethod: (response.verificationMethod || VerificationMethods.EMAIL_OTP) as VerificationMethods, + verificationReason: response.verificationReason as VerificationReasons, + keyFetchToken: response.keyFetchToken, + }, + unwrapBKey: credentials.unwrapBKey, + } + : undefined, + }; } catch (error) { const result = getHandledError(error); if ( diff --git a/packages/fxa-settings/src/pages/Signin/SigninUnblock/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninUnblock/index.tsx index a76734a0a98..38bba7867fe 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninUnblock/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninUnblock/index.tsx @@ -132,7 +132,9 @@ export const SigninUnblock = ({ lastLogin: Date.now(), sessionToken: data.signIn.sessionToken, verified: isFullyVerified, + sessionVerified: data.signIn.sessionVerified, metricsEnabled: data.signIn.metricsEnabled, + hasPassword: true, }; storeAccountData(accountData); @@ -179,7 +181,7 @@ export const SigninUnblock = ({ navigateWithQuery(`/signin`, { state: { email, - // TODO: in FXA-9177, retrieve hasLinkedAccount and hasPassword from Apollo cache + // TODO: in FXA-9177, consider retrieving hasLinkedAccount and hasPassword from localStorage hasLinkedAccount, hasPassword, localizedErrorMessage, diff --git a/packages/fxa-settings/src/pages/Signin/SigninUnblock/mocks.tsx b/packages/fxa-settings/src/pages/Signin/SigninUnblock/mocks.tsx index 1e8dbfbbba4..0193ea2f5ca 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninUnblock/mocks.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninUnblock/mocks.tsx @@ -16,8 +16,6 @@ import { mockGetWebChannelServices, } from '../../mocks'; -export { CREDENTIAL_STATUS_MUTATION, BEGIN_SIGNIN_MUTATION } from '../gql'; - export const MOCK_SIGNIN_UNBLOCK_LOCATION_STATE = { email: MOCK_EMAIL, hasLinkedAccount: false, diff --git a/packages/fxa-settings/src/pages/Signin/container.test.tsx b/packages/fxa-settings/src/pages/Signin/container.test.tsx index 7d7ddd8251b..3ef5768d4c7 100644 --- a/packages/fxa-settings/src/pages/Signin/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/container.test.tsx @@ -10,8 +10,6 @@ import * as CacheModule from '../../lib/cache'; import * as CryptoModule from 'fxa-auth-client/lib/crypto'; import * as SentryModule from '@sentry/browser'; -import { MockedProvider, MockedResponse } from '@apollo/client/testing'; -import { loadErrorMessages, loadDevMessages } from '@apollo/client/dev'; import { LocationProvider } from '@reach/router'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import SigninContainer from './container'; @@ -36,14 +34,6 @@ import { MOCK_UNWRAP_BKEY_V2, MOCK_VERIFICATION, MOCK_KB, - mockBeginSigninMutationWithV2Password, - mockGqlAvatarUseQuery, - mockGqlBeginSigninMutation, - mockGqlCredentialStatusMutation, - mockGqlError, - mockGqlGetAccountKeysMutation, - mockGqlPasswordChangeFinishMutation, - mockGqlPasswordChangeStartMutation, MOCK_FLOW_ID, MOCK_CLIENT_ID, MOCK_KEY_FETCH_TOKEN, @@ -164,6 +154,13 @@ function mockWebIntegration() { expect(integration.isFirefoxClientServiceRelay()).toBeFalsy(); } +function mockFetchModule() { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ id: 'avatar-id', url: 'https://example.com/avatar.png' }), + }); +} + function applyDefaultMocks() { jest.resetAllMocks(); jest.restoreAllMocks(); @@ -178,6 +175,7 @@ function applyDefaultMocks() { mockCurrentAccount({ uid: '123' }); mockCryptoModule(); mockSentryModule(); + mockFetchModule(); } let mockUseCheckReactEmailFirst = jest.fn().mockReturnValue(true); @@ -237,6 +235,33 @@ function mockModelsModule() { mockAuthClient.recoveryKeyExists = jest.fn().mockResolvedValue({ exists: false, }); + // Add auth-client methods used by the container + mockAuthClient.getCredentialStatusV2 = jest.fn().mockResolvedValue({ + upgradeNeeded: false, + currentVersion: 'v1', + clientSalt: MOCK_CLIENT_SALT, + }); + mockAuthClient.signInWithAuthPW = jest.fn().mockResolvedValue({ + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + authAt: MOCK_AUTH_AT, + metricsEnabled: true, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + }); + mockAuthClient.wrappedAccountKeys = jest.fn().mockResolvedValue({ + kA: MOCK_KB, + wrapKB: MOCK_WRAP_KB, + }); + mockAuthClient.passwordChangeStartWithAuthPW = jest.fn().mockResolvedValue({ + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + passwordChangeToken: 'mockPasswordChangeToken', + }); + mockAuthClient.passwordChangeFinish = jest.fn().mockResolvedValue({}); + (ModelsModule.useAuthClient as jest.Mock).mockImplementation( () => mockAuthClient ); @@ -247,6 +272,11 @@ function mockModelsModule() { featureFlags: { recoveryCodeSetupOnSyncSignIn: true, }, + servers: { + profile: { + url: 'http://localhost:1111', + }, + }, })); (ModelsModule.useSession as jest.Mock).mockImplementation(() => mockSession); mockSession.isSessionVerified = jest.fn().mockResolvedValue(true); @@ -381,27 +411,21 @@ function mockSentryModule() { } function render( - mocks: Array, options?: { useFxAStatusResult?: ReturnType } ) { - loadDevMessages(); - loadErrorMessages(); - const useFxAStatusResult = options?.useFxAStatusResult || mockUseFxAStatus(); return renderWithLocalizationProvider( - - - - - + + + ); } @@ -414,7 +438,7 @@ describe('signin container', () => { describe('email', () => { it('can be set from query param', async () => { mockUseValidateModule(); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { expect(currentSigninProps?.email).toBe(MOCK_QUERY_PARAM_EMAIL); }); @@ -423,7 +447,7 @@ describe('signin container', () => { it('router state takes precedence over query param state', async () => { mockUseValidateModule(); mockLocationState = MOCK_LOCATION_STATE_COMPLETE; - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { expect(currentSigninProps?.email).toBe(MOCK_ROUTER_STATE_EMAIL); }); @@ -431,7 +455,7 @@ describe('signin container', () => { }); it('can be set from router state', async () => { mockLocationState = MOCK_LOCATION_STATE_COMPLETE; - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { expect(currentSigninProps?.email).toBe(MOCK_ROUTER_STATE_EMAIL); }); @@ -445,7 +469,7 @@ describe('signin container', () => { }; mockCurrentAccount(storedAccount); mockUseValidateModule(); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { expect(currentSigninProps?.email).toBe(MOCK_QUERY_PARAM_EMAIL); }); @@ -457,14 +481,14 @@ describe('signin container', () => { expect(SigninModule.default).toHaveBeenCalled(); }); it('is handled if not provided in query params or location state', async () => { - render([mockGqlAvatarUseQuery()]); + render(); expect(CacheModule.currentAccount).toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalledWith('/'); expect(SigninModule.default).not.toHaveBeenCalled(); }); it('uses local storage value if email is not provided via query param or router state', async () => { mockCurrentAccount(MOCK_STORED_ACCOUNT); - render([mockGqlAvatarUseQuery()]); + render(); expect(CacheModule.currentAccount).toHaveBeenCalled(); await waitFor(() => { expect(currentSigninProps?.email).toBe(MOCK_STORED_ACCOUNT.email); @@ -480,7 +504,7 @@ describe('signin container', () => { .spyOn(CacheModule, 'lastStoredAccount') .mockReturnValue(LAST_STORED_ACCOUNT); mockCurrentAccount(undefined); - render([mockGqlAvatarUseQuery()]); + render(); expect(CacheModule.currentAccount).toHaveBeenCalled(); expect(CacheModule.lastStoredAccount).toHaveBeenCalled(); await waitFor(() => { @@ -499,7 +523,7 @@ describe('signin container', () => { isV2: () => false, }, }); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { screen.getByLabelText('Loading…'); expect(SigninModule.default).not.toHaveBeenCalled(); @@ -511,7 +535,7 @@ describe('signin container', () => { email: MOCK_ROUTER_STATE_EMAIL, hasLinkedAccount: false, }; - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { screen.getByLabelText('Loading…'); expect(SigninModule.default).not.toHaveBeenCalled(); @@ -529,7 +553,7 @@ describe('signin container', () => { isV2: () => false, }, }); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { expect(mockAuthClient.accountStatusByEmail).toHaveBeenCalledWith( MOCK_QUERY_PARAM_EMAIL, @@ -542,7 +566,7 @@ describe('signin container', () => { email: MOCK_ROUTER_STATE_EMAIL, hasPassword: true, }; - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { expect(mockAuthClient.accountStatusByEmail).toHaveBeenCalledWith( MOCK_ROUTER_STATE_EMAIL, @@ -558,7 +582,7 @@ describe('signin container', () => { .fn() .mockResolvedValue({ exists: false }); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('/signup', { state: { email: MOCK_QUERY_PARAM_EMAIL, emailStatusChecked: true }, @@ -570,14 +594,14 @@ describe('signin container', () => { describe('hasLinkedAccount and hasPassword are provided', () => { it('accountStatusByEmail is not called, email provided by query params', async () => { mockUseValidateModule(); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { expect(mockAuthClient.accountStatusByEmail).not.toHaveBeenCalled(); }); }); it('accountStatusByEmail is not called, email provided by location state', async () => { mockLocationState = MOCK_LOCATION_STATE_COMPLETE; - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { expect(mockAuthClient.accountStatusByEmail).not.toHaveBeenCalled(); }); @@ -590,10 +614,7 @@ describe('signin container', () => { }); it('runs handler and invokes sign in mutation', async () => { - render([ - mockGqlAvatarUseQuery(), - mockGqlBeginSigninMutation({ keys: false }), - ]); + render(); await waitFor(() => { expect(currentSigninProps).toBeDefined(); @@ -629,15 +650,7 @@ describe('signin container', () => { // Ensure early can_link_account check does not short‑circuit beginSigninHandler (ensureCanLinkAcountOrRedirect as jest.Mock).mockResolvedValue(true); mockLocationState = MOCK_LOCATION_STATE_CAN_LINK_ACCOUNT_OK; - render([ - mockGqlAvatarUseQuery(), - mockGqlCredentialStatusMutation({ - currentVersion: 'v2', - upgradeNeeded: false, - clientSalt: MOCK_CLIENT_SALT, - }), - mockGqlBeginSigninMutation({ keys: true, service: 'sync' }), - ]); + render(); }); it('calls recoveryKeyExists when expected and sets showInlineRecoveryKeySetup', async () => { expect(currentSigninProps).toBeDefined(); @@ -704,14 +717,14 @@ describe('signin container', () => { }); }); - it('handles gql mutation error', async () => { - render([ - mockGqlAvatarUseQuery(), - { - ...mockGqlBeginSigninMutation({ keys: false }), - error: mockGqlError(AuthUiErrors.INCORRECT_PASSWORD), - }, - ]); + it('handles auth client error', async () => { + // Mock signInWithAuthPW to throw an error + mockAuthClient.signInWithAuthPW = jest.fn().mockRejectedValue({ + errno: AuthUiErrors.INCORRECT_PASSWORD.errno, + message: AuthUiErrors.INCORRECT_PASSWORD.message, + }); + + render(); await waitFor(async () => { const handlerResult = await currentSigninProps?.beginSigninHandler( @@ -729,24 +742,28 @@ describe('signin container', () => { it('handles incorrect email case error', async () => { const email = `orginal-${MOCK_EMAIL}`; const correctedEmail = `new-${MOCK_EMAIL}`; - await act(() => - render([ - mockGqlAvatarUseQuery(), - // The first call should fail, and the incorrect email case error - // with the corrected email in the error response should be returned. - { - ...mockGqlBeginSigninMutation({ keys: false }, { email: email }), - error: mockGqlError(AuthUiErrors.INCORRECT_EMAIL_CASE, { - email: correctedEmail, - }), - }, - // Note, that originalEmail should also be sent up. This is a requirement for v1 passwords! - mockGqlBeginSigninMutation( - { keys: false, originalLoginEmail: email }, - { email: correctedEmail } - ), - ]) - ); + + // First call should fail with incorrect email case error, then retry with corrected email + mockAuthClient.signInWithAuthPW = jest + .fn() + .mockRejectedValueOnce({ + errno: AuthUiErrors.INCORRECT_EMAIL_CASE.errno, + message: AuthUiErrors.INCORRECT_EMAIL_CASE.message, + email: correctedEmail, + }) + .mockResolvedValue({ + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + authAt: MOCK_AUTH_AT, + metricsEnabled: true, + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + }); + + render(); await waitFor(async () => { // Emulates providing the original email even after they swapped in a primary email. @@ -771,18 +788,7 @@ describe('signin container', () => { // Simulate Index already accepted/auto-OKed the merge warning // avoids mocking ensureCanLinkAcountOrRedirect mockLocationState = { canLinkAccountOk: true }; - render([ - mockGqlAvatarUseQuery(), - mockGqlCredentialStatusMutation({ - currentVersion: 'v2', - upgradeNeeded: false, - clientSalt: MOCK_CLIENT_SALT, - }), - mockGqlBeginSigninMutation( - { keys: true, service: 'sync' }, - { email: MOCK_EMAIL } - ), - ]); + render(); await waitFor(async () => { await currentSigninProps?.beginSigninHandler( @@ -798,18 +804,7 @@ describe('signin container', () => { hasLinkedAccount: undefined, email: MOCK_ROUTER_STATE_EMAIL, }; - render([ - mockGqlAvatarUseQuery(), - mockGqlCredentialStatusMutation({ - currentVersion: 'v2', - upgradeNeeded: false, - clientSalt: MOCK_CLIENT_SALT, - }), - mockGqlBeginSigninMutation( - { keys: true, service: 'sync' }, - { email: MOCK_EMAIL } - ), - ]); + render(); await waitFor(async () => { const handlerResult = await currentSigninProps?.beginSigninHandler( @@ -838,7 +833,7 @@ describe('signin container', () => { email: MOCK_ROUTER_STATE_EMAIL, }; (ensureCanLinkAcountOrRedirect as jest.Mock).mockResolvedValue(false); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(async () => { const handlerResult = await currentSigninProps?.beginSigninHandler( @@ -859,21 +854,7 @@ describe('signin container', () => { supportsCanLinkAccountUid: true, }); - render( - [ - mockGqlAvatarUseQuery(), - mockGqlCredentialStatusMutation({ - currentVersion: 'v2', - upgradeNeeded: false, - clientSalt: MOCK_CLIENT_SALT, - }), - mockGqlBeginSigninMutation( - { keys: true, service: 'sync' }, - { email: MOCK_EMAIL } - ), - ], - { useFxAStatusResult } - ); + render({ useFxAStatusResult }); await waitFor(async () => { const handlerResult = await currentSigninProps?.beginSigninHandler( @@ -906,15 +887,26 @@ describe('signin container', () => { }); it('runs handler and uses existing V2 credentials', async () => { - render([ - mockGqlAvatarUseQuery(), - (() => { - const mock = mockGqlCredentialStatusMutation(); - mock.result.data.credentialStatus.upgradeNeeded = false; - return mock; - })(), - mockBeginSigninMutationWithV2Password(), - ]); + // Mock getCredentialStatusV2 to return upgradeNeeded: false (already on v2) + mockAuthClient.getCredentialStatusV2 = jest.fn().mockResolvedValue({ + upgradeNeeded: false, + currentVersion: 'v2', + clientSalt: MOCK_CLIENT_SALT, + }); + // Mock signInWithAuthPW to return a successful session + mockAuthClient.signInWithAuthPW = jest.fn().mockResolvedValue({ + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + authAt: MOCK_AUTH_AT, + metricsEnabled: true, + emailVerified: true, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + }); + + render(); await waitFor(async () => { const handlerResult = await currentSigninProps?.beginSigninHandler( @@ -929,14 +921,38 @@ describe('signin container', () => { }); it('runs handler and upgrades to new V2 credentials', async () => { - render([ - mockGqlAvatarUseQuery(), - mockGqlCredentialStatusMutation(), - mockGqlPasswordChangeStartMutation(), - mockGqlGetAccountKeysMutation(), - mockGqlPasswordChangeFinishMutation(), - mockBeginSigninMutationWithV2Password(), - ]); + // Mock getCredentialStatusV2 to return upgradeNeeded: true + mockAuthClient.getCredentialStatusV2 = jest.fn().mockResolvedValue({ + upgradeNeeded: true, + currentVersion: 'v1', + clientSalt: MOCK_CLIENT_SALT, + }); + // Mock signInWithAuthPW to return a verified session + mockAuthClient.signInWithAuthPW = jest.fn().mockResolvedValue({ + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + authAt: MOCK_AUTH_AT, + metricsEnabled: true, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + }); + // Mock passwordChangeStartWithAuthPW to succeed + mockAuthClient.passwordChangeStartWithAuthPW = jest.fn().mockResolvedValue({ + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + passwordChangeToken: 'mockPasswordChangeToken', + }); + // Mock wrappedAccountKeys to succeed + mockAuthClient.wrappedAccountKeys = jest.fn().mockResolvedValue({ + kA: MOCK_KB, + wrapKB: MOCK_WRAP_KB, + }); + // Mock passwordChangeFinish to succeed + mockAuthClient.passwordChangeFinish = jest.fn().mockResolvedValue({}); + + render(); await waitFor(async () => { const handlerResult = await currentSigninProps?.beginSigninHandler( @@ -975,14 +991,13 @@ describe('signin container', () => { }); it('handles error fetching credentials status', async () => { - render([ - mockGqlAvatarUseQuery(), - { - ...mockGqlCredentialStatusMutation(), - error: mockGqlError(), - }, - mockGqlBeginSigninMutation({ keys: false }, { email: MOCK_EMAIL }), - ]); + // Mock getCredentialStatusV2 to throw an error + mockAuthClient.getCredentialStatusV2 = jest.fn().mockRejectedValue({ + errno: 999, + message: 'Test error', + }); + + render(); await waitFor(async () => { const handlerResult = await currentSigninProps?.beginSigninHandler( @@ -1000,24 +1015,31 @@ describe('signin container', () => { }); it('handles error when starting upgrade', async () => { - jest.spyOn(console, 'info').mockImplementation(() => {}); - render([ - mockGqlAvatarUseQuery(), - mockGqlCredentialStatusMutation({ - upgradeNeeded: true, - currentVersion: 'v1', - clientSalt: '', - }), - mockGqlBeginSigninMutation( - { keys: false }, - {}, - { emailVerified: true, sessionVerified: true } - ), - { - ...mockGqlPasswordChangeStartMutation(), - error: mockGqlError(), - }, - ]); + // Mock getCredentialStatusV2 to return upgradeNeeded: true + mockAuthClient.getCredentialStatusV2 = jest.fn().mockResolvedValue({ + upgradeNeeded: true, + currentVersion: 'v1', + clientSalt: '', + }); + // Mock signInWithAuthPW to return verified session + mockAuthClient.signInWithAuthPW = jest.fn().mockResolvedValue({ + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + authAt: MOCK_AUTH_AT, + metricsEnabled: true, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + }); + // Mock passwordChangeStartWithAuthPW to throw an error + mockAuthClient.passwordChangeStartWithAuthPW = jest.fn().mockRejectedValue({ + errno: 999, + message: 'Test error', + }); + + render(); await waitFor(async () => { const handlerResult = await currentSigninProps?.beginSigninHandler( @@ -1034,24 +1056,36 @@ describe('signin container', () => { }); it('handles error when fetching keys', async () => { - render([ - mockGqlAvatarUseQuery(), - mockGqlCredentialStatusMutation({ - upgradeNeeded: true, - currentVersion: 'v1', - clientSalt: '', - }), - mockGqlBeginSigninMutation( - { keys: false }, - {}, - { emailVerified: true, sessionVerified: true } - ), - mockGqlPasswordChangeStartMutation(), - { - ...mockGqlGetAccountKeysMutation(), - error: mockGqlError(), - }, - ]); + // Mock getCredentialStatusV2 to return upgradeNeeded: true + mockAuthClient.getCredentialStatusV2 = jest.fn().mockResolvedValue({ + upgradeNeeded: true, + currentVersion: 'v1', + clientSalt: '', + }); + // Mock signInWithAuthPW to return verified session + mockAuthClient.signInWithAuthPW = jest.fn().mockResolvedValue({ + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + authAt: MOCK_AUTH_AT, + metricsEnabled: true, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + }); + // Mock passwordChangeStartWithAuthPW to succeed + mockAuthClient.passwordChangeStartWithAuthPW = jest.fn().mockResolvedValue({ + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + passwordChangeToken: 'mockPasswordChangeToken', + }); + // Mock wrappedAccountKeys to throw an error + mockAuthClient.wrappedAccountKeys = jest.fn().mockRejectedValue({ + errno: 999, + message: 'Test error', + }); + + render(); await waitFor(async () => { const handlerResult = await currentSigninProps?.beginSigninHandler( @@ -1069,25 +1103,41 @@ describe('signin container', () => { }); it('handles error when finishing password upgrade', async () => { - render([ - mockGqlAvatarUseQuery(), - mockGqlCredentialStatusMutation({ - upgradeNeeded: true, - currentVersion: 'v1', - clientSalt: '', - }), - mockGqlBeginSigninMutation( - { keys: false }, - {}, - { emailVerified: true, sessionVerified: true } - ), - mockGqlPasswordChangeStartMutation(), - mockGqlGetAccountKeysMutation(), - { - ...mockGqlPasswordChangeFinishMutation(), - error: mockGqlError(), - }, - ]); + // Mock getCredentialStatusV2 to return upgradeNeeded: true + mockAuthClient.getCredentialStatusV2 = jest.fn().mockResolvedValue({ + upgradeNeeded: true, + currentVersion: 'v1', + clientSalt: '', + }); + // Mock signInWithAuthPW to return verified session + mockAuthClient.signInWithAuthPW = jest.fn().mockResolvedValue({ + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + authAt: MOCK_AUTH_AT, + metricsEnabled: true, + emailVerified: true, + sessionVerified: true, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + }); + // Mock passwordChangeStartWithAuthPW to succeed + mockAuthClient.passwordChangeStartWithAuthPW = jest.fn().mockResolvedValue({ + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + passwordChangeToken: 'mockPasswordChangeToken', + }); + // Mock wrappedAccountKeys to succeed + mockAuthClient.wrappedAccountKeys = jest.fn().mockResolvedValue({ + kA: MOCK_KB, + wrapKB: MOCK_WRAP_KB, + }); + // Mock passwordChangeFinish to throw an error + mockAuthClient.passwordChangeFinish = jest.fn().mockRejectedValue({ + errno: 999, + message: 'Test error', + }); + + render(); await waitFor(async () => { const handlerResult = await currentSigninProps?.beginSigninHandler( @@ -1112,19 +1162,26 @@ describe('signin container', () => { ...mockSession, isSessionVerified: jest.fn().mockResolvedValue(false), })); - render([ - mockGqlAvatarUseQuery(), - mockGqlCredentialStatusMutation({ - upgradeNeeded: true, - currentVersion: 'v1', - }), - // Fallback to the V1 signin! - mockGqlBeginSigninMutation( - { keys: false }, - {}, - { emailVerified: false, sessionVerified: false } - ), - ]); + // Mock getCredentialStatusV2 to return upgradeNeeded: true + mockAuthClient.getCredentialStatusV2 = jest.fn().mockResolvedValue({ + upgradeNeeded: true, + currentVersion: 'v1', + clientSalt: '', + }); + // Mock signInWithAuthPW to return unverified session + mockAuthClient.signInWithAuthPW = jest.fn().mockResolvedValue({ + uid: MOCK_UID, + sessionToken: MOCK_SESSION_TOKEN, + authAt: MOCK_AUTH_AT, + metricsEnabled: true, + emailVerified: false, + sessionVerified: false, + verificationMethod: VerificationMethods.EMAIL_OTP, + verificationReason: VerificationReasons.SIGN_IN, + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + }); + + render(); await waitFor(async () => { const handlerResult = await currentSigninProps?.beginSigninHandler( @@ -1162,7 +1219,7 @@ describe('signin container', () => { }); mockUseValidateModule(); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(() => { expect(currentSigninProps?.email).toBe(MOCK_QUERY_PARAM_EMAIL); @@ -1207,7 +1264,7 @@ describe('signin container', () => { }); mockUseValidateModule(); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(async () => { const handlerResult = @@ -1241,7 +1298,7 @@ describe('signin container', () => { }); mockUseValidateModule(); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(async () => { const handlerResult = @@ -1275,7 +1332,7 @@ describe('signin container', () => { }); mockUseValidateModule(); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(async () => { const handlerResult = @@ -1294,7 +1351,7 @@ describe('signin container', () => { .mockRejectedValue(AuthUiErrors.INVALID_TOKEN); mockUseValidateModule(); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(async () => { const handlerResult = @@ -1315,7 +1372,7 @@ describe('signin container', () => { .fn() .mockRejectedValue(AuthUiErrors.UNEXPECTED_ERROR); mockUseValidateModule(); - render([mockGqlAvatarUseQuery()]); + render(); await waitFor(async () => { const handlerResult = @@ -1341,12 +1398,10 @@ describe('signin container', () => { * double wrap the `render` call in `act(...)` to ensure * the initial render is complete, then the async IIFE completes. */ - async function setupContainer( - mocks: Array = [mockGqlAvatarUseQuery()] - ) { + async function setupContainer() { let container; await act(async () => { - await act(() => (container = render(mocks))); + await act(() => (container = render())); }); return container; } diff --git a/packages/fxa-settings/src/pages/Signin/container.tsx b/packages/fxa-settings/src/pages/Signin/container.tsx index 6248907f821..1f0b7758877 100644 --- a/packages/fxa-settings/src/pages/Signin/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/container.tsx @@ -26,35 +26,21 @@ import { } from '../../models/pages/signin'; import { useCallback, useEffect, useState } from 'react'; import { - cache, currentAccount, lastStoredAccount, findAccountByEmail, } from '../../lib/cache'; -import { MutationFunction, useMutation, useQuery } from '@apollo/client'; -import { - AVATAR_QUERY, - BEGIN_SIGNIN_MUTATION, - CREDENTIAL_STATUS_MUTATION, - GET_ACCOUNT_KEYS_MUTATION, - PASSWORD_CHANGE_FINISH_MUTATION, - PASSWORD_CHANGE_START_MUTATION, -} from './gql'; import { hardNavigate } from 'fxa-react/lib/utils'; import { - AvatarResponse, BeginSigninHandler, BeginSigninResponse, CachedSigninHandler, LocationState, - PasswordChangeStartResponse, - GetAccountKeysResponse, - PasswordChangeFinishResponse, - CredentialStatusResponse, } from './interfaces'; import { getCredentials } from 'fxa-auth-client/lib/crypto'; import { AuthUiErrors } from '../../lib/auth-errors/auth-errors'; import VerificationMethods from '../../constants/verification-methods'; +import VerificationReasons from '../../constants/verification-reasons'; import { KeyStretchExperiment } from '../../models/experiments'; import { useFinishOAuthFlowHandler } from '../../lib/oauth/hooks'; import { searchParams } from '../../lib/utilities'; @@ -74,7 +60,7 @@ import { isFirefoxService, isUnsupportedContext, } from '../../models/integrations/utils'; -import { GqlKeyStretchUpgrade } from '../../lib/gql-key-stretch-upgrade'; +import { AuthKeyStretchUpgrade } from '../../lib/auth-key-stretch-upgrade'; import { setCurrentAccount, storeAccountData, @@ -84,6 +70,9 @@ import { cachedSignIn, ensureCanLinkAcountOrRedirect } from './utils'; import OAuthDataError from '../../components/OAuthDataError'; import { AppLayout } from '../../components/AppLayout'; +/** OAuth token TTL in seconds for profile server requests */ +const PROFILE_OAUTH_TOKEN_TTL_SECONDS = 300; + /* * In Backbone, the `email` param is optional. If it's provided, we * check against it to see if the account exists and if it doesn't, we redirect @@ -196,7 +185,7 @@ const SigninContainer = ({ // email will either come from React (location state) or Backbone (query param) const { email: emailFromLocationState, - // TODO: in FXA-9177, remove hasLinkedAccount and hasPassword, will be retrieved from Apollo cache + // TODO: in FXA-9177, consider storing hasLinkedAccount and hasPassword in localStorage hasLinkedAccount: hasLinkedAccountFromLocationState, hasPassword: hasPasswordFromLocationState, canLinkAccountOk, @@ -209,7 +198,7 @@ const SigninContainer = ({ const [accountStatus, setAccountStatus] = useState({ hasLinkedAccount: - // TODO: in FXA-9177, retrieve hasLinkedAccount and hasPassword from Apollo cache (not state) + // TODO: in FXA-9177, consider retrieving hasLinkedAccount and hasPassword from localStorage hasLinkedAccountFromLocationState !== undefined ? hasLinkedAccountFromLocationState : queryParamModel.hasLinkedAccount, @@ -262,7 +251,7 @@ const SigninContainer = ({ }, }); } else { - // TODO: in FXA-9177, also set hasLinkedAccount and hasPassword in Apollo cache + // TODO: in FXA-9177, consider persisting hasLinkedAccount and hasPassword to localStorage setAccountStatus({ hasLinkedAccount, hasPassword, @@ -288,26 +277,52 @@ const SigninContainer = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const { data: avatarData, loading: avatarLoading } = - useQuery(AVATAR_QUERY); - - const [beginSignin] = useMutation(BEGIN_SIGNIN_MUTATION); - - const [credentialStatus] = useMutation( - CREDENTIAL_STATUS_MUTATION - ); - - const [passwordChangeStart] = useMutation( - PASSWORD_CHANGE_START_MUTATION - ); - - const [passwordChangeFinish] = useMutation( - PASSWORD_CHANGE_FINISH_MUTATION - ); + // Avatar state - fetched directly from profile server + const [avatarData, setAvatarData] = useState<{ account: { avatar: { id: string; url: string } } } | undefined>(undefined); + const [avatarLoading, setAvatarLoading] = useState(true); - const [getWrappedKeys] = useMutation( - GET_ACCOUNT_KEYS_MUTATION - ); + // Fetch avatar on mount from profile server (requires OAuth token) + useEffect(() => { + if (sessionToken && config?.servers?.profile?.url && config?.oauth?.clientId) { + // Get OAuth token with profile:avatar scope (required by profile server) + authClient.createOAuthToken(sessionToken, config.oauth.clientId, { + scope: 'profile:avatar', + ttl: PROFILE_OAUTH_TOKEN_TTL_SECONDS, + }) + .then(({ access_token }) => { + return fetch(`${config.servers.profile.url}/v1/avatar`, { + method: 'GET', + headers: { + Authorization: `Bearer ${access_token}`, + 'Content-Type': 'application/json', + }, + }); + }) + .then((response) => { + if (!response.ok) throw new Error('Failed to fetch avatar'); + return response.json(); + }) + .then((data: { id: string; url: string; avatar?: string }) => { + setAvatarData({ + account: { + avatar: { + id: data.id, + url: data.avatar || data.url, + }, + }, + }); + }) + .catch(() => { + // Avatar fetch failed, use default + setAvatarData(undefined); + }) + .finally(() => { + setAvatarLoading(false); + }); + } else { + setAvatarLoading(false); + } + }, [authClient, config, sessionToken]); const beginSigninHandler: BeginSigninHandler = useCallback( async (email: string, password: string) => { @@ -337,13 +352,7 @@ const SigninContainer = ({ const v2Enabled = keyStretchExp.queryParamModel.isV2(config); // Create client to handle key stretching upgrades - const upgradeClient = new GqlKeyStretchUpgrade( - 'signin', - credentialStatus, - getWrappedKeys, - passwordChangeStart, - passwordChangeFinish - ); + const upgradeClient = new AuthKeyStretchUpgrade('signin', authClient); // Get the current state of user credentials. This could indicate // the user has already upgraded, or it could indicate an upgrade @@ -375,7 +384,7 @@ const SigninContainer = ({ email, v1Credentials, v2Credentials, - beginSignin, + authClient, options, sensitiveDataClient, async (correctedEmail: string) => { @@ -412,22 +421,11 @@ const SigninContainer = ({ ) !== 'true' ) { try { - // We must use auth-client here in case the user has 2FA or should be - // taken to signin_token_code, else GQL responds with 'Invalid token' + // Check recovery key status to determine if we should show inline setup const { exists } = await authClient.recoveryKeyExists( result.data.signIn.sessionToken, email ); - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - recoveryKey() { - return { - exists, - }; - }, - }, - }); result.data.showInlineRecoveryKeySetup = !exists; } catch (e) { // no-op, don't block the user from anything and just @@ -449,7 +447,9 @@ const SigninContainer = ({ lastLogin: Date.now(), sessionToken: result.data.signIn.sessionToken, verified: emailVerified && sessionVerified, + sessionVerified, metricsEnabled: result.data.signIn.metricsEnabled, + hasPassword: true, }; storeAccountData(accountData); @@ -487,14 +487,9 @@ const SigninContainer = ({ return result; }, [ - beginSignin, config, - credentialStatus, - getWrappedKeys, integration, keyStretchExp.queryParamModel, - passwordChangeFinish, - passwordChangeStart, wantsKeys, flowQueryParams, authClient, @@ -508,7 +503,7 @@ const SigninContainer = ({ const cachedSigninHandler: CachedSigninHandler = useCallback( async (sessionToken: hexstring) => - cachedSignIn(sessionToken, authClient, cache, session), + cachedSignIn(sessionToken, authClient, session), [authClient, session] ); @@ -593,7 +588,7 @@ const SigninContainer = ({ }; export async function getCurrentCredentials( - client: GqlKeyStretchUpgrade, + client: AuthKeyStretchUpgrade, email: string, password: string, v2Enabled: boolean @@ -618,12 +613,12 @@ export async function trySignIn( email: string, v1Credentials: { authPW: string; unwrapBKey: string }, v2Credentials: { authPW: string; unwrapBKey: string } | undefined, - beginSignin: MutationFunction, + authClient: ReturnType, options: { verificationMethod: VerificationMethods; keys: boolean; metricsContext: MetricsContext; - service?: any; + service?: string; unblockCode?: string; originalLoginEmail?: string; }, @@ -637,17 +632,16 @@ export async function trySignIn( ) { try { const authPW = v2Credentials?.authPW || v1Credentials.authPW; - const { data } = await beginSignin({ - variables: { - input: { - email, - authPW, - options, - }, - }, + const response = await authClient.signInWithAuthPW(email, authPW, { + verificationMethod: options.verificationMethod, + keys: options.keys, + service: options.service, + metricsContext: options.metricsContext, + unblockCode: options.unblockCode, + originalLoginEmail: options.originalLoginEmail, }); - if (data) { + if (response) { const unwrapBKey = v2Credentials ? v2Credentials.unwrapBKey : v1Credentials.unwrapBKey; @@ -658,17 +652,28 @@ export async function trySignIn( // Store this in case the email was corrected emailForAuth: email, unwrapBKey, - keyFetchToken: data.signIn.keyFetchToken, + keyFetchToken: response.keyFetchToken, }); - return { - data: { - ...data, - ...(options.keys && { - unwrapBKey, - }), + // Transform response to match expected BeginSigninResponse format + const data: BeginSigninResponse = { + signIn: { + uid: response.uid, + sessionToken: response.sessionToken, + authAt: response.authAt, + metricsEnabled: response.metricsEnabled ?? true, + emailVerified: response.emailVerified ?? false, + sessionVerified: response.sessionVerified ?? false, + verificationMethod: (response.verificationMethod || VerificationMethods.EMAIL_OTP) as VerificationMethods, + verificationReason: response.verificationReason as VerificationReasons, + keyFetchToken: response.keyFetchToken, }, + ...(options.keys && { + unwrapBKey, + }), }; + + return { data }; } return { data: undefined }; } catch (error) { @@ -691,7 +696,7 @@ export async function trySignIn( result.error.email, v1Credentials, v2Credentials, - beginSignin, + authClient, { ...options, originalLoginEmail: email, diff --git a/packages/fxa-settings/src/pages/Signin/index.tsx b/packages/fxa-settings/src/pages/Signin/index.tsx index 5002b57053e..e992e3f3ffb 100644 --- a/packages/fxa-settings/src/pages/Signin/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/index.tsx @@ -278,8 +278,7 @@ const Signin = ({ navigateWithQuery('/signin_unblock', { state: { email, - // TODO: in FXA-9177, remove hasLinkedAccount and hasPassword from state - // will be stored in Apollo cache at the container level + // TODO: in FXA-9177, consider persisting hasLinkedAccount and hasPassword to localStorage hasPassword, hasLinkedAccount, }, diff --git a/packages/fxa-settings/src/pages/Signin/mocks.tsx b/packages/fxa-settings/src/pages/Signin/mocks.tsx index f3ac9899ed1..fa619a44545 100644 --- a/packages/fxa-settings/src/pages/Signin/mocks.tsx +++ b/packages/fxa-settings/src/pages/Signin/mocks.tsx @@ -22,17 +22,7 @@ import { MOCK_AVATAR_NON_DEFAULT, mockFinishOAuthFlowHandler, MOCK_CLIENT_ID, - MOCK_AVATAR_DEFAULT, - MOCK_AUTH_PW, - MOCK_CLIENT_SALT, MOCK_KEY_FETCH_TOKEN, - MOCK_PASSWORD_CHANGE_TOKEN, - MOCK_WRAP_KB, - MOCK_AUTH_PW_V2, - MOCK_WRAP_KB_V2, - MOCK_KA, - MOCK_KEY_FETCH_TOKEN_2, - MOCK_FLOW_ID, mockGetWebChannelServices, } from '../mocks'; import { mockUseFxAStatus } from '../../lib/hooks/useFxAStatus/mocks'; @@ -52,16 +42,6 @@ import { AuthUiErrorNos, AuthUiErrors, } from '../../lib/auth-errors/auth-errors'; -import { - AVATAR_QUERY, - BEGIN_SIGNIN_MUTATION, - CREDENTIAL_STATUS_MUTATION, - GET_ACCOUNT_KEYS_MUTATION, - PASSWORD_CHANGE_FINISH_MUTATION, - PASSWORD_CHANGE_START_MUTATION, -} from './gql'; -import { ApolloError } from '@apollo/client'; -import { GraphQLError } from 'graphql'; import { BeginSigninError } from '../../lib/error-utils'; import { mockAppContext } from '../../models/mocks'; import { GenericData } from '../../lib/model-data'; @@ -230,202 +210,6 @@ export function createMockSigninOAuthNativeIntegration({ }; } -export function mockGqlAvatarUseQuery() { - return { - request: { query: AVATAR_QUERY }, - result: { - data: { - account: { - avatar: MOCK_AVATAR_DEFAULT, - }, - }, - }, - }; -} - -export function mockGqlBeginSigninMutation( - opts: { - keys: boolean; - originalLoginEmail?: string; - service?: string; - unblockCode?: string; - }, - inputOverrides: any = {}, - resultOverrides?: { - emailVerified?: boolean; - sessionVerified?: boolean; - } -) { - const result = opts.keys - ? createBeginSigninResponse({ - keyFetchToken: MOCK_KEY_FETCH_TOKEN, - // This doesn't actually come back from the server. We have to 'patch it' with - // the current client side credentials after the request comes back. - // unwrapBKey: MOCK_UNWRAP_BKEY, - }) - : createBeginSigninResponse(); - - // Add ability to override result - if (resultOverrides?.emailVerified !== undefined) { - result.data.signIn.emailVerified = resultOverrides.emailVerified; - } - if (resultOverrides?.sessionVerified !== undefined) { - result.data.signIn.sessionVerified = resultOverrides.sessionVerified; - } - - return { - request: { - query: BEGIN_SIGNIN_MUTATION, - variables: { - input: { - email: MOCK_EMAIL, - authPW: MOCK_AUTH_PW, - ...inputOverrides, - options: { - ...opts, - verificationMethod: VerificationMethods.EMAIL_OTP, - metricsContext: { flowId: MOCK_FLOW_ID }, - }, - }, - }, - }, - result, - }; -} - -export function mockGqlCredentialStatusMutation(overrides?: { - upgradeNeeded?: boolean; - currentVersion?: 'v1' | 'v2'; - clientSalt?: string; -}) { - return { - request: { - query: CREDENTIAL_STATUS_MUTATION, - variables: { - input: 'johndope@example.com', - }, - }, - result: { - data: { - credentialStatus: { - upgradeNeeded: true, - currentVersion: 'v2', - clientSalt: MOCK_CLIENT_SALT, - ...(overrides || {}), - }, - }, - }, - }; -} - -export function mockGqlPasswordChangeStartMutation() { - return { - request: { - query: PASSWORD_CHANGE_START_MUTATION, - variables: { - input: { - email: MOCK_EMAIL, - oldAuthPW: MOCK_AUTH_PW, - sessionToken: MOCK_SESSION_TOKEN, - }, - }, - }, - result: { - data: { - passwordChangeStart: { - keyFetchToken: MOCK_KEY_FETCH_TOKEN, - passwordChangeToken: MOCK_PASSWORD_CHANGE_TOKEN, - }, - }, - }, - }; -} - -export function mockGqlGetAccountKeysMutation() { - return { - request: { - query: GET_ACCOUNT_KEYS_MUTATION, - variables: { - input: MOCK_KEY_FETCH_TOKEN, - }, - }, - result: { - data: { - wrappedAccountKeys: { - kA: MOCK_KA, - wrapKB: MOCK_WRAP_KB, - }, - }, - }, - }; -} - -export function mockGqlPasswordChangeFinishMutation() { - return { - request: { - query: PASSWORD_CHANGE_FINISH_MUTATION, - variables: { - input: { - passwordChangeToken: MOCK_PASSWORD_CHANGE_TOKEN, - authPW: MOCK_AUTH_PW, - wrapKb: MOCK_WRAP_KB, - authPWVersion2: MOCK_AUTH_PW_V2, - wrapKbVersion2: MOCK_WRAP_KB_V2, - clientSalt: MOCK_CLIENT_SALT, - }, - }, - }, - result: { - data: { - passwordChangeFinish: { - uid: MOCK_UID, - sessionToken: MOCK_SESSION_TOKEN, - verified: true, - authAt: 'foo', - keyFetchToken: MOCK_KEY_FETCH_TOKEN, - keyFetchToken2: MOCK_KEY_FETCH_TOKEN_2, - }, - }, - }, - }; -} - -export function mockBeginSigninMutationWithV2Password() { - return { - request: { - query: BEGIN_SIGNIN_MUTATION, - variables: { - input: { - email: MOCK_EMAIL, - authPW: MOCK_AUTH_PW_V2, - options: { - verificationMethod: VerificationMethods.EMAIL_OTP, - keys: false, - metricsContext: { flowId: MOCK_FLOW_ID }, - }, - }, - }, - }, - result: createBeginSigninResponse(), - }; -} - -export function mockGqlError( - error: AuthUiError = AuthUiErrors.UNEXPECTED_ERROR, - extensionOverrides: any = {} -) { - return new ApolloError({ - graphQLErrors: [ - new GraphQLError(error.message, { - extensions: { - errno: error.errno, - ...extensionOverrides, - }, - }), - ], - }); -} - export const MOCK_VERIFICATION = { verificationMethod: VerificationMethods.EMAIL_OTP, verificationReason: VerificationReasons.SIGN_IN, diff --git a/packages/fxa-settings/src/pages/Signin/utils.ts b/packages/fxa-settings/src/pages/Signin/utils.ts index cf5ecde8633..985411ce5c3 100644 --- a/packages/fxa-settings/src/pages/Signin/utils.ts +++ b/packages/fxa-settings/src/pages/Signin/utils.ts @@ -22,7 +22,6 @@ import firefox from '../../lib/channels/firefox'; import { AuthError } from '../../lib/oauth'; import GleanMetrics from '../../lib/glean'; import { OAuthData } from '../../lib/oauth/hooks'; -import { InMemoryCache } from '@apollo/client'; import AuthenticationMethods from '../../constants/authentication-methods'; interface NavigationTarget { @@ -98,7 +97,6 @@ export function getSyncNavigate( export const cachedSignIn = async ( sessionToken: string, authClient: ReturnType, - cache: InMemoryCache, session: ReturnType, isOauthPromptNone = false ) => { @@ -115,20 +113,8 @@ export const cachedSignIn = async ( const totpIsActive = authenticationMethods.includes( AuthenticationMethods.OTP ); - if (totpIsActive) { - // Cache this for subsequent requests - cache.modify({ - id: cache.identify({ __typename: 'Account' }), - fields: { - totp() { - return { exists: true, verified: true }; - }, - }, - }); - } // after accountProfile data is retrieved we must check verified status - // TODO: FXA-9177 can we use the useSession hook here? Or update Apollo Cache const { details } = await authClient.sessionStatus(sessionToken); const sessionVerified = details.sessionVerified; const emailVerified = details.accountEmailVerified; @@ -162,6 +148,8 @@ export const cachedSignIn = async ( uid: storedLocalAccount!.uid, sessionVerified, emailVerified, + // Return TOTP status for components that need it + totpIsActive, }, }; } catch (error) { diff --git a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.test.tsx b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.test.tsx index 46d222e4ee8..a73b024f760 100644 --- a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.test.tsx @@ -8,13 +8,11 @@ import * as ModelsModule from '../../../models'; import * as HooksModule from '../../../lib/oauth/hooks'; import * as OAuthFlowRecoveryModule from '../../../lib/hooks/useOAuthFlowRecovery'; import * as CacheModule from '../../../lib/cache'; -import * as ApolloModule from '@apollo/client'; import * as ReachRouterModule from '@reach/router'; import * as SentryModule from 'fxa-shared/sentry/browser'; import * as ReactUtils from 'fxa-react/lib/utils'; import { screen, waitFor } from '@testing-library/react'; -import AuthClient from 'fxa-auth-client/browser'; import { StoredAccountData } from '../../../lib/storage-utils'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import SignupConfirmCodeContainer from './container'; @@ -61,11 +59,14 @@ jest.mock('../../../lib/glean', () => ({ // Global instances let integration: Integration; -let mockAuthClient = new AuthClient('localhost:9000', { keyStretchVersion: 1 }); let currentProps: any | undefined; -let mockEmailBounceStatusQuery = jest.fn(); const mockSensitiveDataClient = createMockSensitiveDataClient(); +// Mock auth client with emailBounceStatus method +const mockAuthClient = { + emailBounceStatus: jest.fn(), +}; + function mockLocation( originIsSignup: boolean = true, withAccountInfo: boolean = true @@ -87,20 +88,18 @@ function mockReactUtilsModule() { jest.spyOn(ReactUtils, 'hardNavigate').mockImplementation(() => {}); } -function mockEmailBounceQuery() { - mockEmailBounceStatusQuery.mockImplementation(() => { - return { - data: { - emailBounceStatus: { - hasHardBounce: false, - }, - }, - }; +function mockModelsModule() { + mockAuthClient.emailBounceStatus.mockResolvedValue({ hasHardBounce: false }); + (ModelsModule.useAuthClient as jest.Mock).mockImplementation( + () => mockAuthClient + ); + (ModelsModule.useSensitiveDataClient as jest.Mock).mockImplementation( + () => mockSensitiveDataClient + ); + mockSensitiveDataClient.getDataType = jest.fn().mockReturnValue({ + keyFetchToken: MOCK_KEY_FETCH_TOKEN, + unwrapBKey: MOCK_UNWRAP_BKEY, }); - - jest - .spyOn(ApolloModule, 'useQuery') - .mockReturnValue(mockEmailBounceStatusQuery()); } // Apply default mocks @@ -123,16 +122,7 @@ function applyMocks() { return
loading spinner mock
; }); - (ModelsModule.useAuthClient as jest.Mock).mockImplementation( - () => mockAuthClient - ); - (ModelsModule.useSensitiveDataClient as jest.Mock).mockImplementation( - () => mockSensitiveDataClient - ); - mockSensitiveDataClient.getDataType = jest.fn().mockReturnValue({ - keyFetchToken: MOCK_KEY_FETCH_TOKEN, - unwrapBKey: MOCK_UNWRAP_BKEY, - }); + mockModelsModule(); jest .spyOn(HooksModule, 'useFinishOAuthFlowHandler') .mockImplementation(() => { @@ -159,8 +149,6 @@ function applyMocks() { recoveryFailed: false, attemptOAuthFlowRecovery: jest.fn().mockResolvedValue({ success: false }), }); - - mockEmailBounceQuery(); } async function render() { @@ -219,20 +207,11 @@ describe('confirm-signup-container', () => { }); }); - describe('email bounce query', () => { + describe('email bounce status', () => { beforeEach(() => { - mockEmailBounceStatusQuery.mockImplementation(() => { - return { - data: { - emailBounceStatus: { - hasHardBounce: true, - }, - }, - }; + mockAuthClient.emailBounceStatus.mockResolvedValue({ + hasHardBounce: true, }); - jest - .spyOn(ApolloModule, 'useQuery') - .mockReturnValue(mockEmailBounceStatusQuery()); }); it('redirects to email-first signup if there is a bounce on signup', async () => { @@ -241,10 +220,12 @@ describe('confirm-signup-container', () => { await waitFor(() => expect(screen.getByText('confirm signup code mock')).toBeInTheDocument() ); - expect(mockEmailBounceStatusQuery).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith('/', { - state: { hasBounced: true, prefillEmail: MOCK_EMAIL }, - }); + expect(mockAuthClient.emailBounceStatus).toHaveBeenCalledWith(MOCK_EMAIL); + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith('/', { + state: { hasBounced: true, prefillEmail: MOCK_EMAIL }, + }) + ); }); it('redirects to signin_bounced if there is a bounce that is not on signup', async () => { @@ -254,8 +235,10 @@ describe('confirm-signup-container', () => { await waitFor(() => expect(screen.getByText('confirm signup code mock')).toBeInTheDocument() ); - expect(mockEmailBounceStatusQuery).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith('/signin_bounced'); + expect(mockAuthClient.emailBounceStatus).toHaveBeenCalledWith(MOCK_EMAIL); + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith('/signin_bounced') + ); }); }); diff --git a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.tsx b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.tsx index ab01b49504f..5d95b46fdb3 100644 --- a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/container.tsx @@ -2,7 +2,7 @@ * 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, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { RouteComponentProps, useLocation } from '@reach/router'; import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; import { currentAccount } from '../../../lib/cache'; @@ -18,9 +18,7 @@ import { useSensitiveDataClient, } from '../../../models'; import ConfirmSignupCode from '.'; -import { GetEmailBounceStatusResponse, LocationState } from './interfaces'; -import { useQuery } from '@apollo/client'; -import { EMAIL_BOUNCE_STATUS_QUERY } from './gql'; +import { LocationState } from './interfaces'; import OAuthDataError from '../../../components/OAuthDataError'; import { QueryParams } from '../../..'; import { SensitiveData } from '../../../lib/sensitive-data-client'; @@ -103,13 +101,45 @@ const SignupConfirmCodeContainer = ({ // Poll for hard bounces registered in database for the entered email. // Previously, we checked if the account was deleted, and assumed // that implied the email bounced/was invalid. - const { data } = useQuery( - EMAIL_BOUNCE_STATUS_QUERY, - { - variables: { input: email || '' }, - pollInterval: POLL_INTERVAL, - } - ); + const [hasHardBounce, setHasHardBounce] = useState(false); + const pollIntervalRef = useRef(null); + + useEffect(() => { + const checkEmailBounceStatus = async () => { + if (!email) return; + try { + // Type assertion needed until fxa-auth-client is rebuilt with new method + const result = await ( + authClient as typeof authClient & { + emailBounceStatus: ( + email: string + ) => Promise<{ hasHardBounce: boolean }>; + } + ).emailBounceStatus(email); + if (result.hasHardBounce) { + setHasHardBounce(true); + } + } catch (error) { + // Silently fail - we don't want to block the user flow on errors + console.error('Error checking email bounce status:', error); + } + }; + + // Initial check + checkEmailBounceStatus(); + + // Set up polling + pollIntervalRef.current = setInterval( + checkEmailBounceStatus, + POLL_INTERVAL + ); + + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, [authClient, email]); const [recoveryAttempted, setRecoveryAttempted] = useState(false); @@ -149,7 +179,7 @@ const SignupConfirmCodeContainer = ({ // Handle email bounces useEffect(() => { - if (data?.emailBounceStatus.hasHardBounce) { + if (hasHardBounce) { const hasBounced = true; // if arriving from signup, return to '/' and allow user to signup with another email if (origin === 'signup') { @@ -164,7 +194,7 @@ const SignupConfirmCodeContainer = ({ navigateWithQuery('/signin_bounced'); } } - }, [data, origin, navigateWithQuery, email]); + }, [hasHardBounce, origin, navigateWithQuery, email]); const cmsInfo = integration?.getCmsInfo(); const splitLayout = cmsInfo?.SignupConfirmCodePage?.splitLayout; diff --git a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx index bca4f15b541..b0fad65b5f8 100644 --- a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx @@ -197,6 +197,7 @@ const ConfirmSignupCode = ({ uid, // Update verification status of stored current account verified: true, + sessionVerified: true, }); if (hasSelectedNewsletters) { diff --git a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/interfaces.ts b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/interfaces.ts index 7e171fa0d2e..45ad9cfe3da 100644 --- a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/interfaces.ts +++ b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/interfaces.ts @@ -72,7 +72,3 @@ export type ConfirmSignupCodeOAuthIntegration = Pick< export type ConfirmSignupCodeIntegration = | ConfirmSignupCodeBaseIntegration | ConfirmSignupCodeOAuthIntegration; - -export interface GetEmailBounceStatusResponse { - emailBounceStatus: { hasHardBounce: boolean }; -} diff --git a/packages/fxa-settings/src/pages/Signup/container.test.tsx b/packages/fxa-settings/src/pages/Signup/container.test.tsx index 9c09f4d39b7..85ed85e60ab 100644 --- a/packages/fxa-settings/src/pages/Signup/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signup/container.test.tsx @@ -40,7 +40,7 @@ import { MozServices } from '../../lib/types'; import { SignupIntegration, SignupProps } from './interfaces'; import { AuthUiErrors } from '../../lib/auth-errors/auth-errors'; import { ModelDataProvider } from '../../lib/model-data'; -import AuthClient, { AuthServerError } from 'fxa-auth-client/browser'; +import { AuthServerError } from 'fxa-auth-client/browser'; import { LocationProvider } from '@reach/router'; import { MOCK_FLOW_ID, mockGetWebChannelServices } from '../mocks'; @@ -119,8 +119,6 @@ function mockReachRouterModule() { jest.spyOn(ReachRouterModule, 'useNavigate').mockReturnValue(mockNavigate); } -let mockSignUpWithAuthPW = jest.fn(); - // TIP - Occasionally, due to how a module is constructed, jest.spyOn will not work. // In this case, use the following pattern. The jest.mock approach generally works, // but as you can see, it's quite a bit noisier. @@ -130,21 +128,21 @@ jest.mock('../../models', () => { useAuthClient: jest.fn(), }; }); + +// Mock auth client with signUpWithAuthPW method +const mockAuthClient = { + accountStatusByEmail: jest.fn(), + signUpWithAuthPW: jest.fn(), +}; + function mockModelsModule() { - mockSignUpWithAuthPW.mockResolvedValue({ + mockAuthClient.accountStatusByEmail.mockResolvedValue({ exists: true }); + mockAuthClient.signUpWithAuthPW.mockResolvedValue({ uid: 'uid123', keyFetchToken: 'kft123', sessionToken: 'st123', authAt: Date.now(), }); - - let mockAuthClient = new AuthClient('localhost:9000', { - keyStretchVersion: 1, - }); - mockAuthClient.accountStatusByEmail = jest - .fn() - .mockResolvedValue({ exists: true }); - mockAuthClient.signUpWithAuthPW = mockSignUpWithAuthPW; (ModelsModule.useAuthClient as jest.Mock).mockImplementation( () => mockAuthClient ); @@ -285,7 +283,7 @@ describe('sign-up-container', () => { 'test123' ); - expect(mockSignUpWithAuthPW).toHaveBeenCalledWith( + expect(mockAuthClient.signUpWithAuthPW).toHaveBeenCalledWith( 'foo@mozilla.com', 'apw123', {}, @@ -331,7 +329,7 @@ describe('sign-up-container', () => { code: 400, } ); - mockSignUpWithAuthPW.mockRejectedValue(authError); + mockAuthClient.signUpWithAuthPW.mockRejectedValue(authError); await render(); await waitFor(async () => { @@ -348,7 +346,9 @@ describe('sign-up-container', () => { }); it('handles unexpected error on signUpWithAuthPW', async () => { - mockSignUpWithAuthPW.mockRejectedValue(new Error('Network error')); + mockAuthClient.signUpWithAuthPW.mockRejectedValue( + new Error('Network error') + ); await render(); await waitFor(async () => { diff --git a/packages/fxa-settings/src/pages/Signup/container.tsx b/packages/fxa-settings/src/pages/Signup/container.tsx index f54626b2ea6..6515a1d195a 100644 --- a/packages/fxa-settings/src/pages/Signup/container.tsx +++ b/packages/fxa-settings/src/pages/Signup/container.tsx @@ -10,6 +10,7 @@ import { useValidatedQueryParams } from '../../lib/hooks/useValidate'; import { SignupQueryParams } from '../../models/pages/signup'; import { BeginSignupHandler, SignupIntegration } from './interfaces'; import { useCallback, useEffect } from 'react'; +import { handleAuthClientError } from './utils'; import { getCredentials, getCredentialsV2, @@ -18,7 +19,6 @@ import { import { createSaltV2 } from 'fxa-auth-client/lib/salt'; import { SignUpOptions } from 'fxa-auth-client/lib/client'; import { KeyStretchExperiment } from '../../models/experiments/key-stretch-experiment'; -import { handleAuthClientError } from './utils'; import VerificationMethods from '../../constants/verification-methods'; import { queryParamsToMetricsContext } from '../../lib/metrics'; import { QueryParams } from '../..'; diff --git a/packages/fxa-settings/src/pages/Signup/index.tsx b/packages/fxa-settings/src/pages/Signup/index.tsx index d14aa9b6a7e..7af608ebc3b 100644 --- a/packages/fxa-settings/src/pages/Signup/index.tsx +++ b/packages/fxa-settings/src/pages/Signup/index.tsx @@ -144,7 +144,9 @@ export const Signup = ({ lastLogin: Date.now(), sessionToken: data.signUp.sessionToken, verified: false, + sessionVerified: false, metricsEnabled: true, + hasPassword: true, }; // Persist account data to local storage to match parity with content-server diff --git a/packages/fxa-settings/src/setupTests.tsx b/packages/fxa-settings/src/setupTests.tsx index 46cd19ef92b..307a1cb0dc5 100644 --- a/packages/fxa-settings/src/setupTests.tsx +++ b/packages/fxa-settings/src/setupTests.tsx @@ -9,6 +9,18 @@ import { FtlMsgProps } from 'fxa-react/lib/utils'; import { TextEncoder, TextDecoder } from 'util'; import crypto from 'crypto'; +// Suppress console output during tests to reduce noise +// Comment out specific lines below if you need to debug test failures +beforeAll(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + // react-pdf required TextEncoder for EncodeStream // See https://github.com/diegomura/react-pdf/issues/2054#issue-1407270392 global.TextEncoder = TextEncoder; diff --git a/yarn.lock b/yarn.lock index ef63c03e09a..0d4bffb3ba7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36308,7 +36308,6 @@ __metadata: storybook-addon-rtl: "npm:^0.5.0" stream-browserify: "npm:^3.0.0" style-loader: "npm:^4.0.0" - subscriptions-transport-ws: "npm:^0.11.0" tailwindcss: "npm:^3.4.1" terser-webpack-plugin: "npm:^5.2.5" ts-jest: "npm:^29.2.3" @@ -56058,7 +56057,7 @@ __metadata: languageName: node linkType: hard -"subscriptions-transport-ws@npm:0.11.0, subscriptions-transport-ws@npm:^0.11.0": +"subscriptions-transport-ws@npm:0.11.0": version: 0.11.0 resolution: "subscriptions-transport-ws@npm:0.11.0" dependencies: