diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 2db8987d0..a19780a44 100755 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -308,5 +308,80 @@ }, "postInstall_text_wallet_connected_2": { "message": "Access the extension from the browser toolbar." + }, + "postInstallConsent_text_title": { + "message": "Welcome to the Web Monetization Extension" + }, + "postInstallConsent_text_header1": { + "message": "We’re transparent about what data is shared and how it’s used." + }, + "postInstallConsent_text_header2": { + "message": "By continuing, you consent to share the information below." + }, + "postInstallConsent_text_dataShared_title": { + "message": "What’s shared" + }, + "postInstallConsent_text_dataShared_yourWallet_title": { + "message": "With your wallet provider:" + }, + "postInstallConsent_text_dataShared_yourWallet_keyName": { + "message": " If you choose automatic key addition, the extension shares:" + }, + "postInstallConsent_text_dataShared_yourWallet_keyConsent": { + "message": "Note: You will always be asked for consent before any connection." + }, + "postInstallConsent_text_dataShared_yourWallet_headers": { + "message": "Your IP address, language, and browser version while you use the extension." + }, + "postInstallConsent_text_dataShared_websiteWallets_title": { + "message": "With the wallets used on websites you visit that use Web Monetization:" + }, + "postInstallConsent_text_dataShared_websiteWallets_headers": { + "message": "Your IP address, language and browser version information." + }, + "postInstallConsent_text_dataShared_websiteWallets_wa": { + "message": "Your wallet address." + }, + "postInstallConsent_text_dataShared_headers": { + "message": "Browsers send certain HTTP headers by default each request that inform the servers about your language (`Accept-Language` header), browser version (`User-Agent` header) and more. Your IP address is also sent by default." + }, + "postInstallConsent_text_dataNotShared_title": { + "message": "What’s not shared" + }, + "postInstallConsent_text_dataNotShared_walletDetails": { + "message": "Your wallet address, balance, or currency, are never shared with websites you visit." + }, + "postInstallConsent_text_dataNotShared_browsingHistory": { + "message": "Your browsing history. It stays private in your browser." + }, + "postInstallConsent_text_permissions_title": { + "message": "Extension Permissions" + }, + "postInstallConsent_text_permissions_text": { + "message": "The extension needs basic permissions to work." + }, + "postInstallConsent_text_permissions_linkText": { + "message": "See details." + }, + "postInstallConsent_state_consentProvided": { + "message": "You have provided your consent to the above. Access the extension from the browser toolbar." + }, + "postInstallConsent_text_confirmation": { + "message": "I confirm that I understand and consent to this data usage." + }, + "postInstallConsent_action_confirm": { + "message": "Confirm and continue" + }, + "consentRequired_text_title": { + "message": "Consent Required: Data Collection & Transmission" + }, + "consentRequired_text_subtitle": { + "message": "We want to be transparent about what data is shared and how it’s used." + }, + "consentRequired_text_msg": { + "message": "To use the the Web Monetization Extension, we require your consent to our data collection & transmission policy." + }, + "consentRequired_action_primary": { + "message": "View Policy" } } diff --git a/src/background/services/background.ts b/src/background/services/background.ts index 5cf564061..10dcc165d 100644 --- a/src/background/services/background.ts +++ b/src/background/services/background.ts @@ -7,6 +7,8 @@ import { getWalletInformation, isErrorWithKey, moveToFront, + CURRENT_DATA_CONSENT_VERSION, + isConsentRequired, } from '@/shared/helpers'; import { KeyAutoAddService } from '@/background/services/keyAutoAdd'; import { OpenPaymentsClientError } from '@interledger/open-payments/dist/client/error'; @@ -124,7 +126,12 @@ export class Background { } await this.storage.populate(); await this.checkPermissions(); + const { consent } = await this.storage.get(['consent']); + if (isConsentRequired(consent)) { + await this.storage.setState({ consent_required: true }); + } await this.scheduleResetOutOfFundsState(); + await this.updateVisualIndicatorsForCurrentTab().catch(() => {}); } async scheduleResetOutOfFundsState() { @@ -147,12 +154,14 @@ export class Background { } async getAppData(): Promise { - const { connected, publicKey } = await this.storage.get([ + const { connected, publicKey, consent } = await this.storage.get([ 'connected', 'publicKey', + 'consent', ]); return { + consent, connected, publicKey, transientState: this.storage.getPopupTransientState(), @@ -295,6 +304,10 @@ export class Background { await this.monetizationService.pay(message.payload), ); + case 'OPEN_APP': + await this.openAppPage(message.payload.path); + return success(undefined); + // endregion // region Content @@ -337,6 +350,13 @@ export class Background { // region App case 'GET_DATA_APP': return success(await this.getAppData()); + + case 'PROVIDE_CONSENT': { + await this.storage.set({ consent: CURRENT_DATA_CONSENT_VERSION }); + await this.storage.setState({ consent_required: false }); + return success(CURRENT_DATA_CONSENT_VERSION); + } + // endregion default: @@ -406,6 +426,9 @@ export class Background { this.events.on('storage.state_update', async ({ state, prevState }) => { this.sendToPopup.send('SET_STATE', { state, prevState }); await this.updateVisualIndicatorsForCurrentTab(); + if (state.consent_required) { + await this.openAppPage('/post-install/consent'); + } }); this.events.on('monetization.state_update', async (tabId) => { @@ -446,6 +469,9 @@ export class Background { ); } } + if (isConsentRequired(data.consent)) { + await this.storage.setState({ consent_required: true }); + } }); } @@ -460,4 +486,20 @@ export class Background { this.logger.error(error); } }; + + async openAppPage(path: string) { + const appUrl = this.browser.runtime.getURL(APP_URL); + + const allTabs = await this.browser.tabs.query({}); + const appTab = allTabs.find((t) => t.url?.startsWith(appUrl)); + + const url = `${appUrl}#${path}`; + if (appTab?.id) { + await this.browser.tabs.update(appTab.id, { url }); + await this.sendToPopup.send('CLOSE_POPUP', undefined); + return appTab; + } else { + return await this.browser.tabs.create({ url }); + } + } } diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 48bec1cea..ff04561a2 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -410,6 +410,7 @@ export class MonetizationService { async getPopupData(tab: Pick): Promise { const storedData = await this.storage.get([ + 'consent', 'enabled', 'continuousPaymentsEnabled', 'connected', diff --git a/src/background/services/storage.ts b/src/background/services/storage.ts index d7e14dfe5..da3581aca 100644 --- a/src/background/services/storage.ts +++ b/src/background/services/storage.ts @@ -7,9 +7,13 @@ import type { StorageKey, WalletAmount, } from '@/shared/types'; -import { objectEquals, ThrottleBatch } from '@/shared/helpers'; -import { bigIntMax, computeBalance } from '../utils'; -import type { Cradle } from '../container'; +import { + isConsentRequired, + objectEquals, + ThrottleBatch, +} from '@/shared/helpers'; +import { bigIntMax, computeBalance } from '@/background/utils'; +import type { Cradle } from '@/background/container'; const defaultStorage = { /** @@ -20,6 +24,7 @@ const defaultStorage = { * existing installations. */ version: 5, + consent: 0, state: {}, connected: false, enabled: true, @@ -76,8 +81,12 @@ export class StorageService { } async clear(): Promise { - await this.set(defaultStorage); - this.currentState = { ...defaultStorage.state }; + const preservedValues = await this.get(['consent']); + await this.set({ ...defaultStorage, ...preservedValues }); + this.currentState = { + ...defaultStorage.state, + consent_required: isConsentRequired(preservedValues.consent), + }; } /** diff --git a/src/pages/app/App.tsx b/src/pages/app/App.tsx index 8c7b2e254..4cb924270 100644 --- a/src/pages/app/App.tsx +++ b/src/pages/app/App.tsx @@ -11,6 +11,7 @@ import * as PAGES from './pages/index'; export const ROUTES = { DEFAULT: '/', + CONSENT: '/consent', } as const; const P = ROUTES; @@ -18,6 +19,7 @@ const C = PAGES; const Routes = () => ( + ); diff --git a/src/pages/app/lib/store.ts b/src/pages/app/lib/store.ts index 5dd871827..e5320979b 100644 --- a/src/pages/app/lib/store.ts +++ b/src/pages/app/lib/store.ts @@ -25,6 +25,9 @@ export const dispatch = async ({ type, data }: Actions) => { case 'SET_TRANSIENT_STATE': store.transientState = data; break; + case 'SET_CONSENT': + store.consent = data; + break; default: throw new Error('Unknown action'); } @@ -32,4 +35,5 @@ export const dispatch = async ({ type, data }: Actions) => { type Actions = | { type: 'SET_TRANSIENT_STATE'; data: PopupTransientState } - | { type: 'SET_DATA_APP'; data: Pick }; + | { type: 'SET_CONSENT'; data: NonNullable } + | { type: 'SET_DATA_APP'; data: Pick }; diff --git a/src/pages/app/pages/PostInstall.tsx b/src/pages/app/pages/PostInstall.tsx index b2e20de6b..b0497f613 100644 --- a/src/pages/app/pages/PostInstall.tsx +++ b/src/pages/app/pages/PostInstall.tsx @@ -1,16 +1,22 @@ import React from 'react'; +import { Redirect } from 'wouter'; import { ArrowBack, CaretDownIcon, ExternalIcon, } from '@/pages/shared/components/Icons'; -import { getBrowserName, type BrowserName } from '@/shared/helpers'; +import { + getBrowserName, + isConsentRequired, + type BrowserName, +} from '@/shared/helpers'; import { getResponseOrThrow } from '@/shared/messages'; import { useBrowser, useTranslation } from '@/app/lib/context'; import { ConnectWalletForm } from '@/popup/components/ConnectWalletForm'; import { cn } from '@/pages/shared/lib/utils'; import { useMessage } from '@/app/lib/context'; import { useAppState } from '@/app/lib/store'; +import { ROUTES } from '../App'; export default () => { return ( @@ -52,6 +58,12 @@ const Header = () => { const Main = () => { const t = useTranslation(); + const { consent } = useAppState(); + + if (isConsentRequired(consent)) { + return ; + } + return (

diff --git a/src/pages/app/pages/PostInstallConsent.tsx b/src/pages/app/pages/PostInstallConsent.tsx new file mode 100644 index 000000000..eed6e9042 --- /dev/null +++ b/src/pages/app/pages/PostInstallConsent.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { Redirect } from 'wouter'; +import { getBrowserName, isConsentRequired } from '@/shared/helpers'; +import { getResponseOrThrow } from '@/shared/messages'; +import { useBrowser, useMessage, useTranslation } from '@/app/lib/context'; +import { dispatch, useAppState } from '@/app/lib/store'; +import { Button } from '@/pages/shared/components/ui/Button'; +import { InfoCircle } from '@/pages/shared/components/Icons'; +import { ROUTES } from '../App'; + +export default () => { + return ( +
+
+
+
+
+
+ ); +}; + +const Header = () => { + const t = useTranslation(); + return ( +
+ +

+ {t('postInstallConsent_text_title')} +

+
+ ); +}; + +const Main = () => { + const t = useTranslation(); + return ( +
+
+

{t('postInstallConsent_text_header1')}

+

{t('postInstallConsent_text_header2')}

+ + + + +
+ +
+ +
+
+ ); +}; + +function DataShared() { + const t = useTranslation(); + const browser = useBrowser(); + + const browserName = getBrowserName(browser, navigator.userAgent); + const [extensionName, setExtensionName] = React.useState(''); + React.useEffect(() => { + browser.management.getSelf().then((s) => { + setExtensionName(s.name); + }); + }, [browser]); + + return ( +
+

+ {t('postInstallConsent_text_dataShared_title')} +

+
+

+ {t('postInstallConsent_text_dataShared_yourWallet_title')} +

+
    +
  • + {t('postInstallConsent_text_dataShared_yourWallet_keyName')} +
      +
    • Browser name: {browserName}
    • +
    • Extension name: {extensionName}
    • +
    +
  • +
  • + {t('postInstallConsent_text_dataShared_yourWallet_headers')} + +
  • +
+

{t('postInstallConsent_text_dataShared_yourWallet_keyConsent')}

+
+ +
+

+ {t('postInstallConsent_text_dataShared_websiteWallets_title')} +

+
    +
  • + {t('postInstallConsent_text_dataShared_websiteWallets_headers')} + +
  • +
  • {t('postInstallConsent_text_dataShared_websiteWallets_wa')}
  • +
+
+
+ ); +} + +function DataNotShared() { + const t = useTranslation(); + return ( +
+

+ {t('postInstallConsent_text_dataNotShared_title')} +

+
    +
  • {t('postInstallConsent_text_dataNotShared_walletDetails')}
  • +
  • {t('postInstallConsent_text_dataNotShared_browsingHistory')}
  • +
+
+ ); +} + +function Permissions() { + const t = useTranslation(); + return ( +
+

+ {t('postInstallConsent_text_permissions_title')} +

+

+ {t('postInstallConsent_text_permissions_text')}{' '} + + {t('postInstallConsent_text_permissions_linkText')} + +

+
+ ); +} + +function AcceptForm() { + const t = useTranslation(); + const { connected, consent } = useAppState(); + const message = useMessage(); + + const onSubmit = async (ev: React.FormEvent) => { + ev.preventDefault(); + const res = await message.send('PROVIDE_CONSENT'); + const data = getResponseOrThrow(res); + dispatch({ type: 'SET_CONSENT', data }); + }; + + if (!isConsentRequired(consent)) { + if (!connected) { + return ; + } + return ( +
+ +

{t('postInstallConsent_state_consentProvided')}

+
+ ); + } + + return ( +
+ + +
+ ); +} + +function InformationTooltip({ text }: { text: string }) { + return ( + + + + ); +} diff --git a/src/pages/app/pages/index.tsx b/src/pages/app/pages/index.tsx index 113e2da29..a4e793836 100644 --- a/src/pages/app/pages/index.tsx +++ b/src/pages/app/pages/index.tsx @@ -1 +1,2 @@ export { default as PostInstall } from './PostInstall'; +export { default as Consent } from './PostInstallConsent'; diff --git a/src/pages/popup/Popup.tsx b/src/pages/popup/Popup.tsx index 767c25b11..33019b326 100644 --- a/src/pages/popup/Popup.tsx +++ b/src/pages/popup/Popup.tsx @@ -17,6 +17,7 @@ export const ROUTES_PATH = { MISSING_HOST_PERMISSION: '/missing-host-permission', OUT_OF_FUNDS: '/out-of-funds', OUT_OF_FUNDS_ADD_FUNDS: '/out-of-funds/s/add-funds/:recurring?', + CONSENT_REQUIRED: '/consent-required', ERROR_KEY_REVOKED: '/error/key-revoked', } as const; @@ -33,6 +34,7 @@ const Routes = () => ( + diff --git a/src/pages/popup/pages/ConsentRequired.tsx b/src/pages/popup/pages/ConsentRequired.tsx new file mode 100644 index 000000000..4d14a5b85 --- /dev/null +++ b/src/pages/popup/pages/ConsentRequired.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useMessage, useTranslation } from '@/popup/lib/context'; +import { Button } from '@/pages/shared/components/ui/Button'; +import { WarningSign } from '@/pages/shared/components/Icons'; + +export default () => { + const t = useTranslation(); + const message = useMessage(); + return ( +
+
+ +

+ {t('consentRequired_text_title')} +

+
+ +

{t('consentRequired_text_subtitle')}

+ +

{t('consentRequired_text_msg')}

+ + +
+ ); +}; diff --git a/src/pages/popup/pages/Home.tsx b/src/pages/popup/pages/Home.tsx index 3829cd1a7..677d50fe5 100644 --- a/src/pages/popup/pages/Home.tsx +++ b/src/pages/popup/pages/Home.tsx @@ -25,6 +25,9 @@ export default () => { if (state.out_of_funds) { return ; } + if (state.consent_required) { + return ; + } if (connected === false) { return ; } diff --git a/src/pages/popup/pages/index.tsx b/src/pages/popup/pages/index.tsx index 3ef795e3c..8e7789eaa 100644 --- a/src/pages/popup/pages/index.tsx +++ b/src/pages/popup/pages/index.tsx @@ -4,4 +4,5 @@ export { default as Settings } from './Settings'; export { default as MissingHostPermission } from './MissingHostPermission'; export { default as OutOfFunds } from './OutOfFunds'; export { default as OutOfFundsAddFunds } from './OutOfFunds_AddFunds'; +export { default as ConsentRequired } from './ConsentRequired'; export { default as ErrorKeyRevoked } from './ErrorKeyRevoked'; diff --git a/src/pages/shared/components/Icons.tsx b/src/pages/shared/components/Icons.tsx index fc065c533..ed2b513db 100644 --- a/src/pages/shared/components/Icons.tsx +++ b/src/pages/shared/components/Icons.tsx @@ -208,6 +208,26 @@ export const XIcon = (props: React.SVGProps) => { ); }; +export const InfoCircle = (props: React.SVGProps) => { + return ( + + ); +}; + export const CaretDownIcon = (props: React.SVGProps) => { return ( = 1; + +export function isConsentRequired(userConsentVersion: Storage['consent']) { + return userConsentVersion !== CURRENT_DATA_CONSENT_VERSION; +} diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 52a9bda0c..cfbceed99 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -196,6 +196,10 @@ export type PopupToBackgroundMessage = { input: UpdateRateOfPayPayload; output: never; }; + OPEN_APP: { + input: { path: string; action?: string }; + output: never; + }; }; // #endregion @@ -262,6 +266,7 @@ export type AppToBackgroundMessage = { }; CONNECT_WALLET: PopupToBackgroundMessage['CONNECT_WALLET']; RESET_CONNECT_STATE: PopupToBackgroundMessage['RESET_CONNECT_STATE']; + PROVIDE_CONSENT: { input: never; output: NonNullable }; }; // #endregion diff --git a/src/shared/types.ts b/src/shared/types.ts index abb9af427..20928be91 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -57,6 +57,8 @@ export type ExtensionState = | 'missing_host_permissions' /** The public key no longer exists or valid in connected wallet */ | 'key_revoked' + /** The user needs to provide consent to data sharing */ + | 'consent_required' /** The wallet is out of funds, cannot make payments */ | 'out_of_funds'; @@ -67,6 +69,14 @@ export interface Storage { */ version: number; + /** + * Data sharing consent. + * @note When this value in storage is smaller than the "current" consent + * version, the user needs to provide consent again (then update this value). + * @default undefined implies user has never provided consent. + */ + consent?: number; + /** If a wallet is connected or not */ connected: boolean; /** Whether the extension (actually any sort of payment) is enabled */ @@ -147,7 +157,7 @@ export type PopupStore = Omit< }>; }; -export type AppStore = Pick & { +export type AppStore = Pick & { transientState: PopupTransientState; }; diff --git a/tailwind.config.ts b/tailwind.config.ts index a058f47e8..169943ad8 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -22,6 +22,7 @@ module.exports = { primary: 'rgb(var(--text-primary) / )', secondary: 'rgb(var(--text-secondary) / )', 'secondary-dark': 'rgb(var(--text-secondary-dark) / )', + alt: 'rgb(var(--text-alt) / )', weak: 'rgb(var(--text-weak) / )', medium: 'rgb(var(--text-medium) / )', strong: 'rgb(var(--text-strong) / )', diff --git a/tests/e2e/fixtures/helpers.ts b/tests/e2e/fixtures/helpers.ts index 853c6e623..2dacc34e5 100644 --- a/tests/e2e/fixtures/helpers.ts +++ b/tests/e2e/fixtures/helpers.ts @@ -255,6 +255,11 @@ export async function closePostInstallPage( ); let page = context.pages().find((page) => page.url().startsWith(url)); page ??= await context.waitForEvent('page', (p) => p.url().startsWith(url)); + + // Give data sharing consent at once, instead of providing in each test. + await page.getByRole('checkbox').click(); + await page.getByRole('button').click(); + const promise = page.waitForEvent('close'); await page.evaluate(() => window.close()); await promise;