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