diff --git a/eslint-local-rules/persistBackgroundData.txt b/eslint-local-rules/persistBackgroundData.txt index 1062c26acf..1d2c0b3538 100644 --- a/eslint-local-rules/persistBackgroundData.txt +++ b/eslint-local-rules/persistBackgroundData.txt @@ -11,9 +11,10 @@ ./src/analysis/analysisVisitors/regexAnalysis.ts ./src/analysis/analysisVisitors/varAnalysis/varMap.ts ./src/auth/authConstants.ts +./src/auth/authStorage.ts ./src/auth/authTypes.ts ./src/auth/authUtils.ts -./src/auth/token.ts +./src/auth/featureFlags.ts ./src/components/annotationAlert/FieldAnnotationAlert.tsx ./src/components/AsyncButton.tsx ./src/components/DelayedRender.tsx @@ -255,7 +256,6 @@ ./src/store/settings/settingsStorage.ts ./src/store/settings/settingsTypes.ts ./src/store/StorageInterface.ts -./src/store/syncFlags.ts ./src/telemetry/BackgroundLogger.ts ./src/telemetry/deployments.ts ./src/telemetry/dnt.ts diff --git a/jest.config.js b/jest.config.js index 6fc242013a..3e7f0d8ca8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -31,7 +31,7 @@ const config = { "^.+\\.txt$": "/src/testUtils/rawJestTransformer.mjs", }, transformIgnorePatterns: [ - "node_modules/(?!@cfworker|escape-string-regex|filename-reserved-regex|filenamify|idb|webext-|p-timeout|p-retry|p-defer|p-memoize|serialize-error|strip-outer|trim-repeated|mimic-fn|urlpattern-polyfill|url-join|uuid|nanoid|use-debounce|copy-text-to-clipboard|linkify-urls|create-html-element|stringify-attributes|escape-goat|stemmer|uint8array-extras|one-event|abort-utils|batched-function|is-network-error|text-field-edit)", + "node_modules/(?!@cfworker|escape-string-regex|filename-reserved-regex|filenamify|idb|webext-|p-timeout|p-retry|p-defer|p-memoize|serialize-error|strip-outer|trim-repeated|mimic-fn|urlpattern-polyfill|url-join|uuid|nanoid|use-debounce|copy-text-to-clipboard|linkify-urls|create-html-element|stringify-attributes|escape-goat|stemmer|uint8array-extras|one-event|abort-utils|batched-function|is-network-error|text-field-edit|webext-storage-cache|@sindresorhus/to-milliseconds)", ], setupFiles: [ "dotenv/config", diff --git a/package-lock.json b/package-lock.json index 632b7ebafd..5160803351 100644 --- a/package-lock.json +++ b/package-lock.json @@ -148,6 +148,7 @@ "webext-permissions": "^3.1.2", "webext-polyfill-kinda": "^1.0.2", "webext-storage": "^1.2.2", + "webext-storage-cache": "^6.0.0", "webext-tools": "^1.2.3", "webextension-polyfill": "^0.10.0", "whatwg-mimetype": "^4.0.0", @@ -5398,6 +5399,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sindresorhus/to-milliseconds": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/to-milliseconds/-/to-milliseconds-2.0.0.tgz", + "integrity": "sha512-Gkdb2ufaJjg+E2ThIo3MVIV8bqW4ytkSjr/aFE/4Cz1gUZidffhlqlbZGT1jURHB5V5wyMu3KMPz39BSJq/+CA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sindresorhus/tsconfig": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/tsconfig/-/tsconfig-5.0.0.tgz", @@ -28117,6 +28129,39 @@ "url": "https://github.com/sponsors/fregante" } }, + "node_modules/webext-storage-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/webext-storage-cache/-/webext-storage-cache-6.0.0.tgz", + "integrity": "sha512-K04D2fQSdJc4vXbievbDUeTbcopFtRBLZWtIP9sTwbLbgOf9ETVmHU37zCYn1/ElKSsbHc9Nn3o4kgGkY5y9fg==", + "dependencies": { + "@sindresorhus/to-milliseconds": "^2.0.0", + "type-fest": "^3.11.0", + "webext-detect-page": "^4.1.0", + "webext-polyfill-kinda": "^1.0.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/fregante" + } + }, + "node_modules/webext-storage-cache/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webext-storage-cache/node_modules/webext-detect-page": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/webext-detect-page/-/webext-detect-page-4.2.1.tgz", + "integrity": "sha512-saiMkdwrjR5WIoW+clqFCZiGLqbe5Bp3udnPpsaFj6gL3uYTYhpkSjc7givG6gwE3g0oXLPIKOhhP52MrHdK+w==" + }, "node_modules/webext-tools": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/webext-tools/-/webext-tools-1.2.3.tgz", diff --git a/package.json b/package.json index 4cd2388e65..5036734f1a 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "webext-permissions": "^3.1.2", "webext-polyfill-kinda": "^1.0.2", "webext-storage": "^1.2.2", + "webext-storage-cache": "^6.0.0", "webext-tools": "^1.2.3", "webextension-polyfill": "^0.10.0", "whatwg-mimetype": "^4.0.0", diff --git a/scripts/__snapshots__/manifest.test.js.snap b/scripts/__snapshots__/manifest.test.js.snap index 6c55158ab1..c5ec78bcc1 100644 --- a/scripts/__snapshots__/manifest.test.js.snap +++ b/scripts/__snapshots__/manifest.test.js.snap @@ -103,6 +103,7 @@ exports[`customizeManifest mv2 1`] = ` "webNavigation", "contextMenus", "", + "alarms", ], "sandbox": { "pages": [ @@ -241,6 +242,7 @@ exports[`customizeManifest mv3 1`] = ` "tabs", "webNavigation", "contextMenus", + "alarms", "devtools", "scripting", "sidePanel", diff --git a/src/auth/RequireAuth.tsx b/src/auth/RequireAuth.tsx index a49c208eb9..1236ed5611 100644 --- a/src/auth/RequireAuth.tsx +++ b/src/auth/RequireAuth.tsx @@ -19,7 +19,7 @@ import React, { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import Loader from "@/components/Loader"; import { useGetMeQuery } from "@/data/service/api"; -import { clearCachedAuthSecrets, updateUserData } from "@/auth/token"; +import { clearCachedAuthSecrets, updateUserData } from "@/auth/authStorage"; import { selectExtensionAuthState, selectUserDataUpdate, diff --git a/src/auth/authConstants.ts b/src/auth/authConstants.ts index b491913272..7478110c85 100644 --- a/src/auth/authConstants.ts +++ b/src/auth/authConstants.ts @@ -28,7 +28,6 @@ export const anonAuth: AuthState = Object.freeze({ isTestAccount: false, extension: true, scope: null, - flags: [], milestones: [], organizations: [], groups: [], diff --git a/src/auth/authSelectors.ts b/src/auth/authSelectors.ts index 30266e15cc..14856fc28b 100644 --- a/src/auth/authSelectors.ts +++ b/src/auth/authSelectors.ts @@ -21,7 +21,6 @@ export const selectAuth = (state: AuthRootState) => state.auth; export const selectIsLoggedIn = (state: AuthRootState) => selectAuth(state).isLoggedIn; export const selectScope = (state: AuthRootState) => selectAuth(state).scope; -export const selectFlags = (state: AuthRootState) => selectAuth(state).flags; export const selectMilestones = (state: AuthRootState) => selectAuth(state).milestones; export const selectOrganizations = (state: AuthRootState) => diff --git a/src/auth/authSlice.ts b/src/auth/authSlice.ts index 50ac02d715..07530540f6 100644 --- a/src/auth/authSlice.ts +++ b/src/auth/authSlice.ts @@ -33,7 +33,6 @@ export const authSlice = createSlice({ return { ...payload, scope: isEmpty(payload.scope) ? null : payload.scope, - flags: Array.isArray(payload.flags) ? payload.flags : [], organizations: Array.isArray(payload.organizations) ? payload.organizations : [], diff --git a/src/auth/token.ts b/src/auth/authStorage.ts similarity index 100% rename from src/auth/token.ts rename to src/auth/authStorage.ts diff --git a/src/auth/authTypes.ts b/src/auth/authTypes.ts index eae33ae9c0..79912cda1f 100644 --- a/src/auth/authTypes.ts +++ b/src/auth/authTypes.ts @@ -56,10 +56,6 @@ export type UserData = Partial<{ * The user's organization for engagement and error attribution */ telemetryOrganizationId: UUID | null; - /** - * Feature flags - */ - flags: string[]; /** * Organizations the user is a member of */ @@ -108,7 +104,6 @@ export const USER_DATA_UPDATE_KEYS: Array = [ "telemetryOrganizationId", "organizations", "groups", - "flags", "enforceUpdateMillis", "partner", "partnerPrincipals", @@ -235,11 +230,6 @@ export type AuthState = { name: string; }>; - /** - * List of feature flags for the user. - */ - readonly flags: string[]; - /** * List of milestones for the user. A Milestone represents progress through the PixieBrix product. */ diff --git a/src/auth/authUtils.ts b/src/auth/authUtils.ts index 6510ba0640..bfbd9bca73 100644 --- a/src/auth/authUtils.ts +++ b/src/auth/authUtils.ts @@ -17,7 +17,6 @@ import { type Me } from "@/types/contract"; import { type UserDataUpdate, type AuthState } from "@/auth/authTypes"; -import { readAuthData } from "@/auth/token"; // Used by the app function selectOrganizations( @@ -52,7 +51,6 @@ export function selectUserDataUpdate({ telemetry_organization: telemetryOrganization, organization_memberships: organizationMemberships = [], group_memberships = [], - flags = [], partner, enforce_update_millis: enforceUpdateMillis, partner_principals: partnerPrincipals = [], @@ -67,7 +65,6 @@ export function selectUserDataUpdate({ email: email!, organizationId: organization?.id ?? null, telemetryOrganizationId: telemetryOrganization?.id ?? null, - flags, organizations, groups, partner: partner ?? null, @@ -84,7 +81,6 @@ export function selectExtensionAuthState({ telemetry_organization, is_onboarded: isOnboarded, test_account: isTestAccount = false, - flags = [], milestones = [], organization_memberships: organizationMemberships = [], group_memberships = [], @@ -106,18 +102,8 @@ export function selectExtensionAuthState({ telemetryOrganizationId: telemetry_organization?.id, organizations, groups, - flags, milestones, partner, enforceUpdateMillis, }; } - -/** - * Returns true if the specified flag is on for the current user. - * @param flag the feature flag to check - */ -export async function flagOn(flag: string): Promise { - const authData = await readAuthData(); - return authData.flags?.includes(flag) ?? false; -} diff --git a/src/auth/featureFlagStorage.test.ts b/src/auth/featureFlagStorage.test.ts new file mode 100644 index 0000000000..17eb59f2f8 --- /dev/null +++ b/src/auth/featureFlagStorage.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + fetchFeatureFlags, + flagOn, + resetFeatureFlags, + TEST_overrideFeatureFlags, +} from "@/auth/featureFlagStorage"; +import { appApiMock } from "@/testUtils/appApiMock"; +import { TEST_setAuthData, TEST_triggerListeners } from "@/auth/authStorage"; +import { tokenAuthDataFactory } from "@/testUtils/factories/authFactories"; +import { fetchFeatureFlagsInBackground } from "@/background/messenger/strict/api"; + +describe("featureFlags", () => { + beforeEach(async () => { + // Wire up the real fetch function so we can mock the api responses + jest + .mocked(fetchFeatureFlagsInBackground) + .mockImplementation(fetchFeatureFlags); + appApiMock.reset(); + appApiMock.onGet("/api/me/").reply(200, { + flags: [], + }); + }); + + afterEach(async () => { + await resetFeatureFlags(); + }); + + it("returns true if flag is present", async () => { + // eslint-disable-next-line new-cap + await TEST_overrideFeatureFlags([ + "test-flag", + "test-other-flag", + "test-other-flag-2", + ]); + await expect(flagOn("test-flag")).resolves.toBe(true); + }); + + it("returns false if flag is not present", async () => { + // eslint-disable-next-line new-cap + await TEST_overrideFeatureFlags(["test-other-flag", "test-other-flag-2"]); + await expect(flagOn("test-flag")).resolves.toBe(false); + }); + + it("fetches flags on initial storage state", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["test-flag"], + }); + + await expect(flagOn("test-flag")).resolves.toBe(true); + expect(appApiMock.history.get).toHaveLength(1); + }); + + it("does not fetch if flags have been updated recently", async () => { + // eslint-disable-next-line new-cap + await TEST_overrideFeatureFlags(["test-flag"]); + await expect(flagOn("test-flag")).resolves.toBe(true); + expect(appApiMock.history.get).toHaveLength(0); + }); + + it("only fetches once if multiple calls are made", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["test-flag"], + }); + + await expect(flagOn("test-flag")).resolves.toBe(true); + await expect(flagOn("test-flag")).resolves.toBe(true); + await expect(flagOn("test-flag")).resolves.toBe(true); + expect(appApiMock.history.get).toHaveLength(1); + }); + + it("fetches flags again if auth is reset in between calls", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["test-flag", "secret-flag"], + }); + + await expect(flagOn("secret-flag")).resolves.toBe(true); + await expect(flagOn("secret-flag")).resolves.toBe(true); + expect(appApiMock.history.get).toHaveLength(1); + + const authData = tokenAuthDataFactory(); + // eslint-disable-next-line new-cap + await TEST_setAuthData(authData); + // eslint-disable-next-line new-cap + TEST_triggerListeners(authData); + + // New user doesn't have secret flag + appApiMock.onGet("/api/me/").reply(200, { + flags: ["test-flag"], + }); + + await expect(flagOn("secret-flag")).resolves.toBe(false); + await expect(flagOn("secret-flag")).resolves.toBe(false); + expect(appApiMock.history.get).toHaveLength(2); + }); +}); diff --git a/src/auth/featureFlagStorage.ts b/src/auth/featureFlagStorage.ts new file mode 100644 index 0000000000..eefcbf83b4 --- /dev/null +++ b/src/auth/featureFlagStorage.ts @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { getApiClient } from "@/data/service/apiClient"; +import { type components } from "@/types/swagger"; +import { addListener as addAuthStorageListener } from "@/auth/authStorage"; +import { CachedFunction } from "webext-storage-cache"; +import { expectContext } from "@/utils/expectContext"; +import { fetchFeatureFlagsInBackground } from "@/background/messenger/strict/api"; + +export async function fetchFeatureFlags(): Promise { + expectContext("background"); + const client = await getApiClient(); + const { data } = await client.get("/api/me/"); + + return data.flags ?? []; +} + +const featureFlags = new CachedFunction("getFeatureFlags", { + updater: fetchFeatureFlagsInBackground, +}); + +export async function resetFeatureFlags(): Promise { + await featureFlags.delete(); +} + +export async function TEST_overrideFeatureFlags( + flags: string[], +): Promise { + await featureFlags.applyOverride([], flags); +} + +/** + * Returns true if the specified flag is on for the current user. + * @param flag the feature flag to check + */ +export async function flagOn(flag: string): Promise { + const flags = await featureFlags.get(); + return flags.includes(flag); +} + +addAuthStorageListener(async () => { + await resetFeatureFlags(); +}); diff --git a/src/auth/useLinkState.ts b/src/auth/useLinkState.ts index 91ab8ef701..4b2cc95fe9 100644 --- a/src/auth/useLinkState.ts +++ b/src/auth/useLinkState.ts @@ -19,7 +19,7 @@ import { addListener as addAuthListener, isLinked, removeListener as removeAuthListener, -} from "@/auth/token"; +} from "@/auth/authStorage"; import useAsyncState from "@/hooks/useAsyncState"; import { useEffect } from "react"; diff --git a/src/auth/useRequiredPartnerAuth.ts b/src/auth/useRequiredPartnerAuth.ts index 127463ad58..c45d97d278 100644 --- a/src/auth/useRequiredPartnerAuth.ts +++ b/src/auth/useRequiredPartnerAuth.ts @@ -24,7 +24,7 @@ import { addListener as addAuthListener, readPartnerAuthData, removeListener as removeAuthListener, -} from "@/auth/token"; +} from "@/auth/authStorage"; import { useEffect } from "react"; import { AUTOMATION_ANYWHERE_PARTNER_KEY } from "@/data/service/constants"; import { type AuthState } from "@/auth/authTypes"; diff --git a/src/background/deploymentUpdater.test.ts b/src/background/deploymentUpdater.test.ts index 38e92cfb44..d0fc7878f7 100644 --- a/src/background/deploymentUpdater.test.ts +++ b/src/background/deploymentUpdater.test.ts @@ -24,7 +24,7 @@ import MockAdapter from "axios-mock-adapter"; import axios from "axios"; import { updateDeployments } from "@/background/deploymentUpdater"; import reportEvent from "@/telemetry/reportEvent"; -import { isLinked, readAuthData } from "@/auth/token"; +import { isLinked, readAuthData } from "@/auth/authStorage"; import { refreshRegistries } from "@/hooks/useRefreshRegistries"; import { isUpdateAvailable } from "@/background/installer"; import { @@ -73,7 +73,7 @@ jest.mock("@/telemetry/reportEvent"); jest.mock("@/sidebar/messenger/api", () => {}); jest.mock("@/contentScript/messenger/api"); -jest.mock("@/auth/token", () => ({ +jest.mock("@/auth/authStorage", () => ({ getExtensionToken: async () => "TESTTOKEN", getAuthHeaders: jest.fn().mockResolvedValue({}), readAuthData: jest.fn().mockResolvedValue({ diff --git a/src/background/deploymentUpdater.ts b/src/background/deploymentUpdater.ts index b710c375a6..ba7fd45a97 100644 --- a/src/background/deploymentUpdater.ts +++ b/src/background/deploymentUpdater.ts @@ -19,7 +19,7 @@ import { type Deployment, type Me } from "@/types/contract"; import { isEmpty, partition } from "lodash"; import reportError from "@/telemetry/reportError"; import { getUUID } from "@/telemetry/telemetryHelpers"; -import { isLinked, readAuthData, updateUserData } from "@/auth/token"; +import { isLinked, readAuthData, updateUserData } from "@/auth/authStorage"; import reportEvent from "@/telemetry/reportEvent"; import { refreshRegistries } from "@/hooks/useRefreshRegistries"; import { diff --git a/src/background/installer.test.ts b/src/background/installer.test.ts index 735bb1b6c9..ec9a76a705 100644 --- a/src/background/installer.test.ts +++ b/src/background/installer.test.ts @@ -20,7 +20,7 @@ import { openInstallPage, handleInstall, } from "@/background/installer"; -import * as auth from "@/auth/token"; +import * as auth from "@/auth/authStorage"; import { locator } from "@/background/locator"; import { uuidv4 } from "@/types/helpers"; import { waitForEffect } from "@/testUtils/testHelpers"; @@ -33,7 +33,7 @@ jest.mock("@/data/service/baseService", () => ({ getBaseURL: jest.fn().mockResolvedValue("https://app.pixiebrix.com"), })); -jest.mock("@/auth/token", () => ({ +jest.mock("@/auth/authStorage", () => ({ isLinked: jest.fn().mockResolvedValue(false), getExtensionToken: jest.fn().mockResolvedValue(null), getUserData: jest.fn().mockResolvedValue(null), @@ -41,10 +41,6 @@ jest.mock("@/auth/token", () => ({ jest.mock("@/background/telemetry"); -jest.mock("@/store/syncFlags", () => ({ - syncFlagOn: jest.fn().mockResolvedValue(false), -})); - jest.mock("@/background/locator", () => ({ locator: { locateAllForService: jest.fn().mockResolvedValue([]), diff --git a/src/background/installer.ts b/src/background/installer.ts index 63685f3d2a..05a5d419eb 100644 --- a/src/background/installer.ts +++ b/src/background/installer.ts @@ -22,7 +22,7 @@ import { getUUID } from "@/telemetry/telemetryHelpers"; import { allowsTrack, dntConfig } from "@/telemetry/dnt"; import { gt } from "semver"; import { getBaseURL } from "@/data/service/baseService"; -import { getExtensionToken, getUserData, isLinked } from "@/auth/token"; +import { getExtensionToken, getUserData, isLinked } from "@/auth/authStorage"; import { isCommunityControlRoom } from "@/contrib/automationanywhere/aaUtils"; import { isEmpty } from "lodash"; import { expectContext } from "@/utils/expectContext"; diff --git a/src/background/makeConfiguredRequest.test.ts b/src/background/makeConfiguredRequest.test.ts index f315fca9af..3ba5565238 100644 --- a/src/background/makeConfiguredRequest.test.ts +++ b/src/background/makeConfiguredRequest.test.ts @@ -19,7 +19,7 @@ import serviceRegistry from "@/integrations/registry"; import axios, { type AxiosError, type AxiosRequestConfig } from "axios"; import MockAdapter from "axios-mock-adapter"; import { performConfiguredRequest } from "./requests"; -import * as token from "@/auth/token"; +import * as token from "@/auth/authStorage"; import Locator, * as locator from "@/integrations/locator"; import { validateRegistryId } from "@/types/helpers"; import enrichAxiosErrors from "@/utils/enrichAxiosErrors"; @@ -55,7 +55,7 @@ jest.mock("@/background/auth/getToken", () => ({ __esModule: true, getToken: jest.fn().mockResolvedValue({ token: "iamatoken" }), })); -jest.mock("@/auth/token"); +jest.mock("@/auth/authStorage"); jest.mock("@/integrations/locator"); // Use real version of pixiebrixConfigurationFactory diff --git a/src/background/messenger/external/_implementation.ts b/src/background/messenger/external/_implementation.ts index 64b6e75fd5..33696bc117 100644 --- a/src/background/messenger/external/_implementation.ts +++ b/src/background/messenger/external/_implementation.ts @@ -19,7 +19,7 @@ * @file THIS FILE IS MEANT TO BE IMPORTED EXCLUSIVELY BY ./api.js */ -import { linkExtension } from "@/auth/token"; +import { linkExtension } from "@/auth/authStorage"; import { type TokenAuthData } from "@/auth/authTypes"; import reportEvent from "@/telemetry/reportEvent"; import { Events } from "@/telemetry/events"; diff --git a/src/background/messenger/external/api.ts b/src/background/messenger/external/api.ts index a56271714c..83801cd14f 100644 --- a/src/background/messenger/external/api.ts +++ b/src/background/messenger/external/api.ts @@ -22,7 +22,7 @@ import { _liftBackground as liftExternal } from "@/background/externalProtocol"; import * as local from "@/background/messenger/external/_implementation"; -import { readPartnerAuthData } from "@/auth/token"; +import { readPartnerAuthData } from "@/auth/authStorage"; export const connectPage = liftExternal("CONNECT_PAGE", async () => browser.runtime.getManifest(), diff --git a/src/background/messenger/strict/api.ts b/src/background/messenger/strict/api.ts index 3fe6829ff8..9931c5cc2e 100644 --- a/src/background/messenger/strict/api.ts +++ b/src/background/messenger/strict/api.ts @@ -68,3 +68,8 @@ export const clearExtensionDebugLogs = getMethod( "CLEAR_EXTENSION_DEBUG_LOGS", bg, ); + +export const fetchFeatureFlagsInBackground = getMethod( + "FETCH_FEATURE_FLAGS", + bg, +); diff --git a/src/background/messenger/strict/registration.ts b/src/background/messenger/strict/registration.ts index 36514aafab..68896ad2e0 100644 --- a/src/background/messenger/strict/registration.ts +++ b/src/background/messenger/strict/registration.ts @@ -39,7 +39,7 @@ import { rememberFocus } from "@/utils/focusTracker"; import writeToClipboardInFocusedContext from "@/background/clipboard"; import * as registry from "@/registry/packageRegistry"; import serviceRegistry from "@/integrations/registry"; -import { getUserData } from "@/auth/token"; +import { getUserData } from "@/auth/authStorage"; import { clearExtensionDebugLogs, clearLog, @@ -47,6 +47,7 @@ import { recordError, recordLog, } from "@/telemetry/logging"; +import { fetchFeatureFlags } from "@/auth/featureFlagStorage"; expectContext("background"); @@ -72,6 +73,7 @@ declare global { REGISTRY_GET_BY_KINDS: typeof registry.getByKinds; REGISTRY_FIND: typeof registry.find; QUERY_TABS: typeof browser.tabs.query; + FETCH_FEATURE_FLAGS: typeof fetchFeatureFlags; CLEAR_SERVICE_CACHE: typeof serviceRegistry.clear; GET_USER_DATA: typeof getUserData; @@ -105,6 +107,7 @@ export default function registerMessenger(): void { REGISTRY_GET_BY_KINDS: registry.getByKinds, REGISTRY_FIND: registry.find, QUERY_TABS: browser.tabs.query, + FETCH_FEATURE_FLAGS: fetchFeatureFlags, CLEAR_SERVICE_CACHE: serviceRegistry.clear.bind(serviceRegistry), GET_USER_DATA: getUserData, diff --git a/src/background/navigation.ts b/src/background/navigation.ts index 088a0231e3..0247018f36 100644 --- a/src/background/navigation.ts +++ b/src/background/navigation.ts @@ -23,7 +23,7 @@ import { isScriptableUrl } from "webext-content-scripts"; import { debounce } from "lodash"; import { canAccessTab as canInjectTab, getTabUrl } from "webext-tools"; import { getTargetState } from "@/contentScript/ready"; -import { flagOn } from "@/auth/authUtils"; +import { flagOn } from "@/auth/featureFlagStorage"; export function reactivateEveryTab(): void { console.debug("Reactivate all tabs"); diff --git a/src/background/partnerIntegrations.test.ts b/src/background/partnerIntegrations.test.ts index e9ee6b3f96..1906be5154 100644 --- a/src/background/partnerIntegrations.test.ts +++ b/src/background/partnerIntegrations.test.ts @@ -24,7 +24,7 @@ import tokenIntegrationDefinition from "@contrib/integrations/automation-anywher import oauthIntegrationDefinition from "@contrib/integrations/automation-anywhere-oauth2.yaml"; import { locator as serviceLocator } from "@/background/locator"; import { uuidv4 } from "@/types/helpers"; -import { readPartnerAuthData, setPartnerAuth } from "@/auth/token"; +import { readPartnerAuthData, setPartnerAuth } from "@/auth/authStorage"; import { syncRemotePackages } from "@/registry/memoryRegistry"; import { type RegistryId } from "@/types/registryTypes"; import { type IntegrationConfig } from "@/integrations/integrationTypes"; @@ -45,7 +45,7 @@ const integrationDefinitionMap = new Map([ [CONTROL_ROOM_OAUTH_INTEGRATION_ID, oauthIntegrationDefinition], ]); -jest.mock("@/auth/token", () => ({ +jest.mock("@/auth/authStorage", () => ({ readPartnerAuthData: jest.fn().mockResolvedValue({}), setPartnerAuth: jest.fn(), })); diff --git a/src/background/partnerIntegrations.ts b/src/background/partnerIntegrations.ts index 4bbbc96913..02754719ac 100644 --- a/src/background/partnerIntegrations.ts +++ b/src/background/partnerIntegrations.ts @@ -20,7 +20,7 @@ import { flatten, isEmpty } from "lodash"; import { expectContext } from "@/utils/expectContext"; import { type RegistryId } from "@/types/registryTypes"; import launchOAuth2Flow from "@/background/auth/launchOAuth2Flow"; -import { readPartnerAuthData, setPartnerAuth } from "@/auth/token"; +import { readPartnerAuthData, setPartnerAuth } from "@/auth/authStorage"; import serviceRegistry from "@/integrations/registry"; import axios from "axios"; import { getBaseURL } from "@/data/service/baseService"; diff --git a/src/background/requests.ts b/src/background/requests.ts index 4647086258..e4a8add162 100644 --- a/src/background/requests.ts +++ b/src/background/requests.ts @@ -23,7 +23,7 @@ import axios, { } from "axios"; import { pixiebrixConfigurationFactory } from "@/integrations/locator"; import serviceRegistry from "@/integrations/registry"; -import { getExtensionToken } from "@/auth/token"; +import { getExtensionToken } from "@/auth/authStorage"; import { locator } from "@/background/locator"; import { isEmpty } from "lodash"; import launchOAuth2Flow from "@/background/auth/launchOAuth2Flow"; diff --git a/src/background/restrictUnauthenticatedUrlAccess.test.ts b/src/background/restrictUnauthenticatedUrlAccess.test.ts index b94ba2d0c5..09f7146667 100644 --- a/src/background/restrictUnauthenticatedUrlAccess.test.ts +++ b/src/background/restrictUnauthenticatedUrlAccess.test.ts @@ -25,13 +25,13 @@ import { isLinked, TEST_clearListeners, TEST_triggerListeners, -} from "@/auth/token"; +} from "@/auth/authStorage"; import { waitForEffect } from "@/testUtils/testHelpers"; import { tabFactory } from "@/testUtils/factories/browserFactories"; -jest.mock("@/auth/token", () => ({ +jest.mock("@/auth/authStorage", () => ({ __esModule: true, - ...jest.requireActual("@/auth/token"), + ...jest.requireActual("@/auth/authStorage"), isLinked: jest.fn(), })); diff --git a/src/background/restrictUnauthenticatedUrlAccess.ts b/src/background/restrictUnauthenticatedUrlAccess.ts index 9e10782ca2..bf072ec191 100644 --- a/src/background/restrictUnauthenticatedUrlAccess.ts +++ b/src/background/restrictUnauthenticatedUrlAccess.ts @@ -17,7 +17,7 @@ import { type OrganizationAuthUrlPattern } from "@/types/contract"; import { readManagedStorage } from "@/store/enterprise/managedStorage"; -import { addListener as addAuthListener, isLinked } from "@/auth/token"; +import { addListener as addAuthListener, isLinked } from "@/auth/authStorage"; import { validateUUID } from "@/types/helpers"; import { type UUID } from "@/types/stringTypes"; import { testMatchPatterns } from "@/bricks/available"; diff --git a/src/background/starterMods.test.ts b/src/background/starterMods.test.ts index a6edde9b3d..2d8d677a3f 100644 --- a/src/background/starterMods.test.ts +++ b/src/background/starterMods.test.ts @@ -25,7 +25,7 @@ import { } from "@/store/extensionsStorage"; import MockAdapter from "axios-mock-adapter"; import axios from "axios"; -import { isLinked } from "@/auth/token"; +import { isLinked } from "@/auth/authStorage"; import { refreshRegistries } from "./refreshRegistries"; import { type ActivatedModComponent, @@ -42,7 +42,7 @@ import { remoteIntegrationConfigurationFactory } from "@/testUtils/factories/int const axiosMock = new MockAdapter(axios); -jest.mock("@/auth/token", () => ({ +jest.mock("@/auth/authStorage", () => ({ async getAuthHeaders() { return {}; }, diff --git a/src/background/telemetry.test.ts b/src/background/telemetry.test.ts index c578a709f3..a8cfa9d83b 100644 --- a/src/background/telemetry.test.ts +++ b/src/background/telemetry.test.ts @@ -23,10 +23,6 @@ import { import { appApiMock } from "@/testUtils/appApiMock"; import { type Event } from "@/telemetry/events"; -jest.mock("@/store/syncFlags", () => ({ - syncFlagOn: jest.fn().mockResolvedValue(true), -})); - beforeEach(async () => { appApiMock.reset(); appApiMock.onPost("/api/events/").reply(201, {}); diff --git a/src/background/telemetry.ts b/src/background/telemetry.ts index 54df339978..44e1773cab 100644 --- a/src/background/telemetry.ts +++ b/src/background/telemetry.ts @@ -17,7 +17,7 @@ import { type JsonObject } from "type-fest"; import { compact, debounce, throttle, uniq } from "lodash"; -import { isLinked } from "@/auth/token"; +import { isLinked } from "@/auth/authStorage"; import { getModComponentState } from "@/store/extensionsStorage"; import { getLinkedApiClient, diff --git a/src/bricks/readers/SessionReader.ts b/src/bricks/readers/SessionReader.ts index 53f314e301..754e7fa6d6 100644 --- a/src/bricks/readers/SessionReader.ts +++ b/src/bricks/readers/SessionReader.ts @@ -17,7 +17,7 @@ import { ReaderABC } from "@/types/bricks/readerTypes"; import * as session from "@/contentScript/context"; -import { getExtensionAuth } from "@/auth/token"; +import { getExtensionAuth } from "@/auth/authStorage"; import { type JsonObject } from "type-fest"; import { type Schema } from "@/types/schemaTypes"; diff --git a/src/components/floatingActions/initFloatingActions.ts b/src/components/floatingActions/initFloatingActions.ts index 872c30b7e1..c372fd9edf 100644 --- a/src/components/floatingActions/initFloatingActions.ts +++ b/src/components/floatingActions/initFloatingActions.ts @@ -19,7 +19,7 @@ import { isLoadedInIframe } from "@/utils/iframeUtils"; import { getSettingsState } from "@/store/settings/settingsStorage"; import { getUserData } from "@/background/messenger/api"; import { DEFAULT_THEME } from "@/themes/themeTypes"; -import { syncFlagOn } from "@/store/syncFlags"; +import { flagOn } from "@/auth/featureFlagStorage"; /** * Add the floating action button to the page if the user is not an enterprise/partner user. @@ -44,13 +44,13 @@ export default async function initFloatingActions(): Promise { const isEnterpriseOrPartnerUser = Boolean(telemetryOrganizationId) || settings.theme !== DEFAULT_THEME; + const hasFeatureFlag = await flagOn("floating-quickbar-button-freemium"); + // Add floating action button if the feature flag and settings are enabled // XXX: consider moving checks into React component, so we can use the Redux context if ( settings.isFloatingActionButtonEnabled && - // XXX: there's likely a race here with when syncFlagOn gets the flag from localStorage. But in practice, this - // seems to work fine. (Likely because the flags will be loaded by the time the Promise.all above resolves) - syncFlagOn("floating-quickbar-button-freemium") && + hasFeatureFlag && !isEnterpriseOrPartnerUser ) { const { renderFloatingActions } = await import( diff --git a/src/components/quickBar/QuickBarApp.tsx b/src/components/quickBar/QuickBarApp.tsx index 080ee9480a..c1d999076e 100644 --- a/src/components/quickBar/QuickBarApp.tsx +++ b/src/components/quickBar/QuickBarApp.tsx @@ -47,10 +47,10 @@ import defaultActions, { pageEditorAction, } from "@/components/quickBar/defaultActions"; import quickBarRegistry from "@/components/quickBar/quickBarRegistry"; -import { flagOn } from "@/auth/authUtils"; import { onContextInvalidated } from "webext-events"; import StopPropagation from "@/components/StopPropagation"; import useScrollLock from "@/hooks/useScrollLock"; +import { flagOn } from "@/auth/featureFlagStorage"; /** * Set to true if the KBar should be displayed on initial mount (i.e., because it was triggered by the diff --git a/src/components/quickBar/useActions.test.tsx b/src/components/quickBar/useActions.test.tsx index 65f98855d9..001cee7801 100644 --- a/src/components/quickBar/useActions.test.tsx +++ b/src/components/quickBar/useActions.test.tsx @@ -25,12 +25,8 @@ import defaultActions, { import quickBarRegistry from "@/components/quickBar/quickBarRegistry"; import { initQuickBarApp } from "@/components/quickBar/QuickBarApp"; -jest.mock("@/auth/token", () => ({ - __esModule: true, - ...jest.requireActual("@/auth/token"), - readAuthData: jest.fn().mockResolvedValue({ - flags: [], - }), +jest.mock("@/auth/featureFlagStorage", () => ({ + flagOn: jest.fn().mockReturnValue(false), })); beforeAll(async () => { diff --git a/src/contentScript/loadActivationEnhancementsCore.test.ts b/src/contentScript/loadActivationEnhancementsCore.test.ts index 6fe934bfb0..94d31b33b9 100644 --- a/src/contentScript/loadActivationEnhancementsCore.test.ts +++ b/src/contentScript/loadActivationEnhancementsCore.test.ts @@ -32,7 +32,7 @@ import { TEST_unloadActivationEnhancements, } from "@/contentScript/loadActivationEnhancementsCore"; import { isReadyInThisDocument } from "@/contentScript/ready"; -import { isLinked } from "@/auth/token"; +import { isLinked } from "@/auth/authStorage"; import { array } from "cooky-cutter"; import { MARKETPLACE_URL } from "@/urlConstants"; @@ -42,7 +42,7 @@ jest.mock("@/contentScript/sidebarController", () => ({ showModActivationInSidebar: jest.fn(), })); -jest.mock("@/auth/token", () => ({ +jest.mock("@/auth/authStorage", () => ({ isLinked: jest.fn().mockResolvedValue(true), })); diff --git a/src/contentScript/sidebarActivation.ts b/src/contentScript/sidebarActivation.ts index 0db86deb27..c63520fd13 100644 --- a/src/contentScript/sidebarActivation.ts +++ b/src/contentScript/sidebarActivation.ts @@ -21,7 +21,7 @@ import { showSidebar, sidePanelOnClose, } from "@/contentScript/sidebarController"; -import { isLinked } from "@/auth/token"; +import { isLinked } from "@/auth/authStorage"; import { getActivatingMods, setActivatingMods, diff --git a/src/data/service/api.ts b/src/data/service/api.ts index 3a55119d23..54e5a0f1c2 100644 --- a/src/data/service/api.ts +++ b/src/data/service/api.ts @@ -73,11 +73,20 @@ export const appApi = createApi({ query: () => ({ url: "/api/me/", method: "get", - // The /api/me/ endpoint returns a blank result if not authenticated - requireLinked: false, }), providesTags: ["Me"], }), + getFeatureFlags: builder.query({ + query: () => ({ + url: "/api/me/", + method: "get", + // The /api/me/ endpoint returns an object with only feature flags if not authenticated + requireLinked: false, + }), + transformResponse: (response: components["schemas"]["Me"]) => [ + ...(response.flags ?? []), + ], + }), getDatabases: builder.query({ query: () => ({ url: "/api/databases/", method: "get" }), providesTags: ["Databases"], @@ -170,7 +179,7 @@ export const appApi = createApi({ }), }), getMarketplaceListing: builder.query< - MarketplaceListing, + MarketplaceListing | undefined, { packageId: RegistryId } >({ query: (params) => ({ @@ -432,6 +441,7 @@ export const appApi = createApi({ export const { useGetMeQuery, + useGetFeatureFlagsQuery, useGetDatabasesQuery, useCreateDatabaseMutation, useAddDatabaseToGroupMutation, diff --git a/src/data/service/apiClient.ts b/src/data/service/apiClient.ts index b4fc023f54..05b57feff2 100644 --- a/src/data/service/apiClient.ts +++ b/src/data/service/apiClient.ts @@ -17,7 +17,7 @@ import axios, { type AxiosInstance } from "axios"; import { getBaseURL } from "@/data/service/baseService"; -import { getAuthHeaders } from "@/auth/token"; +import { getAuthHeaders } from "@/auth/authStorage"; import { ExtensionNotLinkedError, SuspiciousOperationError, diff --git a/src/data/service/baseQuery.ts b/src/data/service/baseQuery.ts index 594ce5ca0b..2f5c5d625c 100644 --- a/src/data/service/baseQuery.ts +++ b/src/data/service/baseQuery.ts @@ -45,7 +45,7 @@ type QueryArgs = { /** * Optional additional metadata to pass through to the result. */ - meta?: unknown; + meta?: UnknownObject; /** * Optional URL parameters to be sent with the request diff --git a/src/data/service/errorService.ts b/src/data/service/errorService.ts index ff2b58d6cd..b423581459 100644 --- a/src/data/service/errorService.ts +++ b/src/data/service/errorService.ts @@ -23,7 +23,7 @@ import { } from "@/errors/errorHelpers"; import { allowsTrack } from "@/telemetry/dnt"; import { uuidv4, validateSemVerString } from "@/types/helpers"; -import { getUserData } from "@/auth/token"; +import { getUserData } from "@/auth/authStorage"; import { isAppRequestError, selectAbsoluteUrl, @@ -36,8 +36,8 @@ import { type SerializedError } from "@/types/messengerTypes"; import { type SemVerString } from "@/types/registryTypes"; import { type MessageContext } from "@/types/loggerTypes"; import { isObject } from "@/utils/objectUtils"; -import { flagOn } from "@/auth/authUtils"; import type { Timestamp } from "@/types/stringTypes"; +import { flagOn } from "@/auth/featureFlagStorage"; const EVENT_BUFFER_DEBOUNCE_MS = 2000; const EVENT_BUFFER_MAX_MS = 10_000; diff --git a/src/extensionConsole/Navbar.tsx b/src/extensionConsole/Navbar.tsx index 5b27fbf916..cb439987c0 100644 --- a/src/extensionConsole/Navbar.tsx +++ b/src/extensionConsole/Navbar.tsx @@ -27,7 +27,7 @@ import { addListener as addAuthListener, readPartnerAuthData, removeListener as removeAuthListener, -} from "@/auth/token"; +} from "@/auth/authStorage"; import { useSelector } from "react-redux"; import { toggleSidebar } from "./toggleSidebar"; import { type SettingsState } from "@/store/settings/settingsTypes"; diff --git a/src/extensionConsole/pages/integrations/ConnectExtensionCard.tsx b/src/extensionConsole/pages/integrations/ConnectExtensionCard.tsx index aa314e569d..d50f015124 100644 --- a/src/extensionConsole/pages/integrations/ConnectExtensionCard.tsx +++ b/src/extensionConsole/pages/integrations/ConnectExtensionCard.tsx @@ -18,7 +18,7 @@ import React, { useState } from "react"; import { getBaseURL } from "@/data/service/baseService"; import { useAsyncEffect } from "use-async-effect"; -import { isLinked } from "@/auth/token"; +import { isLinked } from "@/auth/authStorage"; import { Card } from "react-bootstrap"; import urljoin from "url-join"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; diff --git a/src/extensionConsole/pages/settings/AdvancedSettings.tsx b/src/extensionConsole/pages/settings/AdvancedSettings.tsx index 8670757fb4..2ec80164f5 100644 --- a/src/extensionConsole/pages/settings/AdvancedSettings.tsx +++ b/src/extensionConsole/pages/settings/AdvancedSettings.tsx @@ -21,7 +21,7 @@ import styles from "./SettingsCard.module.scss"; import { Button, Card, Form } from "react-bootstrap"; import { useConfiguredHost } from "@/data/service/baseService"; import React, { useCallback } from "react"; -import { clearCachedAuthSecrets, clearPartnerAuth } from "@/auth/token"; +import { clearCachedAuthSecrets, clearPartnerAuth } from "@/auth/authStorage"; import notify from "@/utils/notify"; import useFlags from "@/hooks/useFlags"; import settingsSlice, { diff --git a/src/hooks/useFlags.test.tsx b/src/hooks/useFlags.test.tsx new file mode 100644 index 0000000000..9baa6f49ed --- /dev/null +++ b/src/hooks/useFlags.test.tsx @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from "react"; +import { appApiMock } from "@/testUtils/appApiMock"; +import { render, renderHook } from "@/pageEditor/testHelpers"; +import useFlags from "@/hooks/useFlags"; +import { waitForEffect } from "@/testUtils/testHelpers"; +import { TEST_setAuthData, TEST_triggerListeners } from "@/auth/authStorage"; +import { tokenAuthDataFactory } from "@/testUtils/factories/authFactories"; + +const TestComponent: React.FC<{ name: string }> = ({ name, children }) => { + const { flagOn } = useFlags(); + + return ( +
+ Test Component {name} + {flagOn(`test-flag-${name}`) &&
Test flag is on for {name}
} + {children} +
+ ); +}; + +describe("useFlags", () => { + beforeEach(async () => { + appApiMock.reset(); + }); + + it("only fetches once for multiple instances of the hook in nested/sibling components", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["test-flag-parent", "test-flag-child1"], + }); + + const { rerender } = render( + + + + , + ); + + await waitForEffect(); + // Make sure we only fetched once + expect(appApiMock.history.get).toHaveLength(1); + + // Mount another component instance (grandchild1), so that another + // subscriber is added to the query in the hook + rerender( + + + + + + , + ); + + await waitForEffect(); + // Make sure we've still only fetched once + expect(appApiMock.history.get).toHaveLength(1); + }); + + it("re-fetches flags when the auth data changes", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["test-flag-parent", "test-flag-child1"], + }); + + const tokenData = tokenAuthDataFactory(); + + // eslint-disable-next-line new-cap + await TEST_setAuthData(tokenData); + + render( + + + + , + ); + + await waitForEffect(); + // Make sure we only fetched once + expect(appApiMock.history.get).toHaveLength(1); + + // Simulate a change in auth data + // eslint-disable-next-line new-cap + TEST_triggerListeners(tokenData); + + await waitForEffect(); + // Should have fetched again since the auth data has changed + expect(appApiMock.history.get).toHaveLength(2); + }); + + describe("flagOn", () => { + it("returns true if flag is present", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["test-flag"], + }); + + const { result, waitFor } = renderHook(() => useFlags()); + + await waitFor(() => { + expect(result.current.flagOn("test-flag")).toBe(true); + }); + }); + + it("returns false if flag is not present", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: [], + }); + + const { result, waitFor } = renderHook(() => useFlags()); + + await waitFor(() => { + expect(result.current.flagOn("test-flag")).toBe(false); + }); + }); + }); + + describe("flagOff", () => { + it("returns true if flag is not present", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: [], + }); + + const { result, waitFor } = renderHook(() => useFlags()); + + await waitFor(() => { + expect(result.current.flagOff("test-flag")).toBe(true); + }); + }); + + it("returns false if flag is present", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["test-flag"], + }); + + const { result, waitFor } = renderHook(() => useFlags()); + + await waitFor(() => { + expect(result.current.flagOff("test-flag")).toBe(false); + }); + }); + }); + + describe("permit", () => { + it("returns true if restricted flag for area is not present", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["restricted-workshop"], + }); + + const { result, waitFor } = renderHook(() => useFlags()); + + await waitFor(() => { + expect(result.current.permit("page-editor")).toBe(true); + }); + }); + + it("returns false if restricted flag for area is present", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["restricted-workshop", "restricted-page-editor"], + }); + + const { result, waitFor } = renderHook(() => useFlags()); + + await waitFor(() => { + expect(result.current.permit("page-editor")).toBe(false); + }); + }); + }); + + describe("restrict", () => { + it("returns true if restricted flag for area is present", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["restricted-workshop", "restricted-page-editor"], + }); + + const { result, waitFor } = renderHook(() => useFlags()); + + await waitFor(() => { + expect(result.current.restrict("page-editor")).toBe(true); + }); + }); + + it("returns false if restricted flag for area is not present", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["restricted-workshop"], + }); + + const { result, waitFor } = renderHook(() => useFlags()); + + await waitFor(() => { + expect(result.current.restrict("page-editor")).toBe(false); + }); + }); + }); +}); diff --git a/src/hooks/useFlags.ts b/src/hooks/useFlags.ts index a44150494d..e9ad55d1de 100644 --- a/src/hooks/useFlags.ts +++ b/src/hooks/useFlags.ts @@ -15,9 +15,12 @@ * along with this program. If not, see . */ -import { useMemo } from "react"; -import { selectFlags } from "@/auth/authSelectors"; -import { useSelector } from "react-redux"; +import { useEffect, useMemo } from "react"; +import { useGetFeatureFlagsQuery } from "@/data/service/api"; +import { + addListener as addAuthStorageListener, + removeListener as removeAuthStorageListener, +} from "@/auth/authStorage"; const RESTRICTED_PREFIX = "restricted"; @@ -46,7 +49,19 @@ type Restrict = { * For permit/restrict, features will be restricted in the fetching/loading state */ function useFlags(): Restrict { - const flags = useSelector(selectFlags); + const { data: flags, refetch } = useGetFeatureFlagsQuery(); + + useEffect(() => { + const listener = () => { + void refetch(); + }; + + addAuthStorageListener(listener); + + return () => { + removeAuthStorageListener(listener); + }; + }, [refetch]); return useMemo(() => { const flagSet = new Set(flags); diff --git a/src/layout/EnvironmentBanner.tsx b/src/layout/EnvironmentBanner.tsx index 1e33e9aaeb..c006615c38 100644 --- a/src/layout/EnvironmentBanner.tsx +++ b/src/layout/EnvironmentBanner.tsx @@ -16,7 +16,7 @@ */ import React from "react"; -import { getExtensionAuth } from "@/auth/token"; +import { getExtensionAuth } from "@/auth/authStorage"; import { isExtensionContext } from "webext-detect-page"; import { connectPage } from "@/background/messenger/external/api"; import Banner, { type BannerVariant } from "@/components/banner/Banner"; diff --git a/src/manifest.json b/src/manifest.json index 25f401ee1e..d07c94ad9b 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -51,7 +51,8 @@ "tabs", "webNavigation", "contextMenus", - "" + "", + "alarms" ], "devtools_page": "devtools.html", "externally_connectable": { diff --git a/src/pageEditor/sidebar/AddStarterBrickButton.tsx b/src/pageEditor/sidebar/AddStarterBrickButton.tsx index d65e95cfc2..4b59fbb093 100644 --- a/src/pageEditor/sidebar/AddStarterBrickButton.tsx +++ b/src/pageEditor/sidebar/AddStarterBrickButton.tsx @@ -24,13 +24,13 @@ import { sortBy } from "lodash"; import useAddElement from "@/pageEditor/hooks/useAddElement"; import { useSelector } from "react-redux"; import { selectTabHasPermissions } from "@/pageEditor/tabState/tabStateSelectors"; -import { flagOn } from "@/auth/authUtils"; import useAsyncState from "@/hooks/useAsyncState"; import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons"; import reportEvent from "@/telemetry/reportEvent"; import { Events } from "@/telemetry/events"; import { selectSessionId } from "@/pageEditor/slices/sessionSelectors"; import { inspectedTab } from "@/pageEditor/context/connection"; +import { flagOn } from "@/auth/featureFlagStorage"; const sortedStarterBricks = sortBy( [...ADAPTERS.values()], diff --git a/src/sidebar/DefaultContent.test.tsx b/src/sidebar/DefaultContent.test.tsx index c70ef4effe..af55c03048 100644 --- a/src/sidebar/DefaultContent.test.tsx +++ b/src/sidebar/DefaultContent.test.tsx @@ -19,11 +19,10 @@ import React from "react"; import { render, screen } from "@/sidebar/testHelpers"; import DefaultPanel from "./DefaultPanel"; import extensionsSlice from "@/store/extensionsSlice"; -import { authSlice } from "@/auth/authSlice"; -import { type AuthState } from "@/auth/authTypes"; import { type ActivatedModComponent } from "@/types/modComponentTypes"; import { modComponentFactory } from "@/testUtils/factories/modComponentFactories"; import { type Timestamp } from "@/types/stringTypes"; +import { appApiMock } from "@/testUtils/appApiMock"; describe("renders DefaultPanel", () => { it("renders Page Editor call to action", () => { @@ -32,7 +31,11 @@ describe("renders DefaultPanel", () => { expect(screen.getByText("Get started with PixieBrix")).not.toBeNull(); }); - it("renders restricted user content", () => { + it("renders restricted user content", async () => { + appApiMock.onGet("/api/me/").reply(200, { + flags: ["restricted-marketplace"], + }); + render(, { setupRedux(dispatch) { dispatch( @@ -43,15 +46,11 @@ describe("renders DefaultPanel", () => { }, }), ); - - dispatch( - authSlice.actions.setAuth({ - flags: ["restricted-marketplace"], - } as AuthState), - ); }, }); - expect(screen.getByText("No panels activated for the page")).not.toBeNull(); + await expect( + screen.findByText("No panels activated for the page"), + ).resolves.toBeVisible(); }); }); diff --git a/src/starterBricks/quickBarExtension.test.ts b/src/starterBricks/quickBarExtension.test.ts index 4306f2b349..a68d49fd5d 100644 --- a/src/starterBricks/quickBarExtension.test.ts +++ b/src/starterBricks/quickBarExtension.test.ts @@ -52,12 +52,8 @@ const rootReaderId = validateRegistryId("test/root-reader"); mockAnimationsApi(); -jest.mock("@/auth/token", () => ({ - __esModule: true, - ...jest.requireActual("@/auth/token"), - readAuthData: jest.fn().mockResolvedValue({ - flags: [], - }), +jest.mock("@/auth/featureFlagStorage", () => ({ + flagOn: jest.fn().mockReturnValue(false), })); const starterBrickFactory = (definitionOverrides: UnknownObject = {}) => diff --git a/src/starterBricks/quickbarProviderExtension.test.ts b/src/starterBricks/quickbarProviderExtension.test.ts index fc3bbb51ad..22d13ea40f 100644 --- a/src/starterBricks/quickbarProviderExtension.test.ts +++ b/src/starterBricks/quickbarProviderExtension.test.ts @@ -51,12 +51,8 @@ import { getPlatform } from "@/platform/platformContext"; const rootReaderId = validateRegistryId("test/root-reader"); mockAnimationsApi(); -jest.mock("@/auth/token", () => ({ - __esModule: true, - ...jest.requireActual("@/auth/token"), - readAuthData: jest.fn().mockResolvedValue({ - flags: [], - }), +jest.mock("@/auth/featureFlagStorage", () => ({ + flagOn: jest.fn().mockReturnValue(false), })); const starterBrickFactory = (definitionOverrides: UnknownObject = {}) => diff --git a/src/starterBricks/tourExtension.test.ts b/src/starterBricks/tourExtension.test.ts index a4aeb06511..4ec5a554e2 100644 --- a/src/starterBricks/tourExtension.test.ts +++ b/src/starterBricks/tourExtension.test.ts @@ -41,12 +41,8 @@ import { getPlatform } from "@/platform/platformContext"; const rootReader = new RootReader(); -jest.mock("@/auth/token", () => ({ - __esModule: true, - ...jest.requireActual("@/auth/token"), - readAuthData: jest.fn().mockResolvedValue({ - flags: [], - }), +jest.mock("@/auth/featureFlagStorage", () => ({ + flagOn: jest.fn().mockReturnValue(false), })); const starterBrickFactory = (definitionOverrides: UnknownObject = {}) => diff --git a/src/store/syncFlags.ts b/src/store/syncFlags.ts deleted file mode 100644 index 1a53e75963..0000000000 --- a/src/store/syncFlags.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2024 PixieBrix, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { getUserData, addListener as addAuthListener } from "@/auth/token"; -import { expectContext } from "@/utils/expectContext"; -import { type UserData } from "@/auth/authTypes"; - -// eslint-disable-next-line local-rules/persistBackgroundData -- Just a local cache of stored data -let flags = new Set(); - -async function initFlags() { - expectContext("extension"); - const { flags: initialFlags = [] } = await getUserData(); - flags = new Set(initialFlags); -} - -addAuthListener(async ({ flags: newFlags = [] }: UserData) => { - flags = new Set(newFlags); -}); - -/** - * Synchronously check if a flag is on. Prefer useFlags and token.ts:flagOn where possible. - * @param flag the flag to check - * @see useFlags - * @see flagOn - */ -export function syncFlagOn(flag: string): boolean { - return flags.has(flag); -} - -// Load flags on initial module load -// XXX: might consider exporting as init method that entry modules can call, instead of automatically calling -void initFlags(); diff --git a/src/telemetry/initErrorReporter.ts b/src/telemetry/initErrorReporter.ts index d5b006e915..ff121dcc2d 100644 --- a/src/telemetry/initErrorReporter.ts +++ b/src/telemetry/initErrorReporter.ts @@ -16,7 +16,10 @@ */ import { isContentScript } from "webext-detect-page"; -import { addListener as addAuthListener, readAuthData } from "@/auth/token"; +import { + addListener as addAuthListener, + readAuthData, +} from "@/auth/authStorage"; import type { UserData } from "@/auth/authTypes"; import pMemoize from "p-memoize"; import { datadogLogs } from "@datadog/browser-logs"; diff --git a/src/telemetry/logging.test.ts b/src/telemetry/logging.test.ts index af5d84d58d..968098b45a 100644 --- a/src/telemetry/logging.test.ts +++ b/src/telemetry/logging.test.ts @@ -31,13 +31,13 @@ import { } from "@/testUtils/factories/logFactories"; import { array } from "cooky-cutter"; import { registryIdFactory } from "@/testUtils/factories/stringFactories"; -import { flagOn } from "@/auth/authUtils"; import type { ErrorObject } from "serialize-error"; +import { flagOn } from "@/auth/featureFlagStorage"; // Disable automatic __mocks__ resolution jest.mock("@/telemetry/logging", () => jest.requireActual("./logging.ts")); -jest.mock("@/auth/authUtils", () => ({ +jest.mock("@/auth/featureFlagStorage", () => ({ flagOn: jest.fn().mockRejectedValue(new Error("Not mocked")), })); diff --git a/src/telemetry/logging.ts b/src/telemetry/logging.ts index 8d090ff665..7f7ef42f24 100644 --- a/src/telemetry/logging.ts +++ b/src/telemetry/logging.ts @@ -42,7 +42,7 @@ import { type UUID } from "@/types/stringTypes"; import { deleteDatabase } from "@/utils/idbUtils"; import { memoizeUntilSettled } from "@/utils/promiseUtils"; import { StorageItem } from "webext-storage"; -import { flagOn } from "@/auth/authUtils"; +import { flagOn } from "@/auth/featureFlagStorage"; const DATABASE_NAME = "LOG"; const ENTRY_OBJECT_STORE = "entries"; diff --git a/src/telemetry/performance.ts b/src/telemetry/performance.ts index 3607f698c5..f992641e90 100644 --- a/src/telemetry/performance.ts +++ b/src/telemetry/performance.ts @@ -19,13 +19,16 @@ import { datadogRum } from "@datadog/browser-rum"; import { getDNT } from "@/telemetry/dnt"; import { getBaseURL } from "@/data/service/baseService"; import { expectContext, forbidContext } from "@/utils/expectContext"; -import { addListener as addAuthListener, readAuthData } from "@/auth/token"; -import { flagOn } from "@/auth/authUtils"; +import { + addListener as addAuthListener, + readAuthData, +} from "@/auth/authStorage"; import { cleanDatadogVersionName, mapAppUserToTelemetryUser, } from "@/telemetry/telemetryHelpers"; import type { UserData } from "@/auth/authTypes"; +import { flagOn } from "@/auth/featureFlagStorage"; const environment = process.env.ENVIRONMENT; const applicationId = process.env.DATADOG_APPLICATION_ID; diff --git a/src/testUtils/factories/authFactories.ts b/src/testUtils/factories/authFactories.ts index 771a0cd227..e4b671c016 100644 --- a/src/testUtils/factories/authFactories.ts +++ b/src/testUtils/factories/authFactories.ts @@ -97,10 +97,6 @@ export const authStateFactory = define({ const groups: AuthState["groups"] = []; return groups; }, - flags() { - const flags: AuthState["flags"] = []; - return flags; - }, milestones(): Milestone[] { return []; }, @@ -148,9 +144,6 @@ export const tokenAuthDataFactory = define({ email: emailFactory, user: uuidSequence, hostname: "app.pixiebrix.com", - flags(): string[] { - return []; - }, organizations(): UserData["organizations"] { return []; }, diff --git a/src/testUtils/userMock.ts b/src/testUtils/userMock.ts index a264688976..0aef18402f 100644 --- a/src/testUtils/userMock.ts +++ b/src/testUtils/userMock.ts @@ -21,7 +21,7 @@ import { userFactory, } from "@/testUtils/factories/authFactories"; import { appApiMock } from "@/testUtils/appApiMock"; -import { TEST_setAuthData } from "@/auth/token"; +import { TEST_setAuthData } from "@/auth/authStorage"; // In existing code, there was a lot of places mocking both useQueryState and useGetMeQuery. This could in some places // yield impossible states due to how `skip` logic in calls like RequireAuth, etc. @@ -39,7 +39,6 @@ export async function mockAuthenticatedUser(me?: Me): Promise { const tokenData = tokenAuthDataFactory({ email: user.email, user: user.id, - flags: user.flags, }); // eslint-disable-next-line new-cap await TEST_setAuthData(tokenData); diff --git a/src/tsconfig.strictNullChecks.json b/src/tsconfig.strictNullChecks.json index 0a2a311a73..57f2818341 100644 --- a/src/tsconfig.strictNullChecks.json +++ b/src/tsconfig.strictNullChecks.json @@ -33,9 +33,10 @@ "./auth/authConstants.ts", "./auth/authSelectors.ts", "./auth/authSlice.ts", + "./auth/authStorage.ts", "./auth/authTypes.ts", "./auth/authUtils.ts", - "./auth/token.ts", + "./auth/featureFlagStorage.ts", "./auth/useLinkState.ts", "./background/auth/authStorage.ts", "./background/auth/codeGrantFlow.ts", @@ -587,7 +588,6 @@ "./store/settings/settingsSlice.ts", "./store/settings/settingsStorage.ts", "./store/settings/settingsTypes.ts", - "./store/syncFlags.ts", "./store/workshopSlice.ts", "./telemetry/deployments.ts", "./telemetry/dnt.ts",