diff --git a/_scripts/clone-legal-docs.sh b/_scripts/clone-legal-docs.sh index 0911c22f5a..12dee222d7 100755 --- a/_scripts/clone-legal-docs.sh +++ b/_scripts/clone-legal-docs.sh @@ -5,8 +5,9 @@ # and file into `public/legal-docs` if present. # # Additionally, this script outputs `legal-docs/[name]_locales.json` with an array of locales -# (directories) that the file was found in to make it easy to know what's available instead -# of traversing directories when determining which locale to show the user. +# that indicating the locales that are supported. These json files originate from the +# .github/stats.json file in the legal-docs repo. +# # ensure the script errors out when a command fails set -e @@ -67,14 +68,18 @@ get_realpath_missing() { } # END: workaround for missing GNU realpath in BSD/MacOS-X -DOWNLOAD_PATH="${FXA_LEGAL_DOCS_DOWNLOAD_PATH:-legal-docs}" SCRIPT_DIR="$(dirname "$0")" +DOWNLOAD_PATH="${FXA_LEGAL_DOCS_DOWNLOAD_PATH:-$(get_realpath_missing "$SCRIPT_DIR/../external/legal-docs")}" MODULE_PATH="$(get_realpath_missing "$SCRIPT_DIR/../packages/$MODULE")" +LOCAL_PATH="$MODULE_PATH/public/legal-docs" [ -d "$MODULE_PATH" ] || error "The module/package does not exist at: $MODULE_PATH" -# Clone and pull -cd "$MODULE_PATH" +echo "MODULE_PATH: $MODULE_PATH" +echo "LOCAL_PATH: $LOCAL_PATH" +echo "DOWNLOAD_PATH: $DOWNLOAD_PATH" + +mkdir -p "$LOCAL_PATH" if [ -n "${FXA_LEGAL_DOCS_COPY_FROM}" ]; then echo "Using LEGAL_DOCS files from: ${FXA_LEGAL_DOCS_COPY_FROM}" @@ -130,18 +135,22 @@ copy_md() { log "Copying $md_name and parent locale directories into $MODULE_PATH/public/legal-docs/" + cd "$DOWNLOAD_PATH" results=() for src in **"/${md_name}.md"; do [ -f "$src" ] || continue - legal_docs_dir="$MODULE_PATH/public/legal-docs/$(dirname "$src" | sed -E 's/^.*\/([a-zA-Z_]+)\/.+/\1/; s/_/-/g')" + legal_docs_dir="$LOCAL_PATH/$(dirname "$src" | sed -E 's/^.*\/([a-zA-Z_]+)\/.+/\1/; s/_/-/g')" [ -d "$legal_docs_dir" ] || mkdir -p "$legal_docs_dir" cp "$src" "$legal_docs_dir/$md_name.md" results+=("$(echo "$legal_docs_dir" | sed -E 's/.+\/([a-zA-Z_]+)/\1/; s/-/_/g')") done - log "Creating .json file containing array of available locales in $MODULE_PATH/public/legal-docs/${md_name}_locales.json" + cat "$LOCAL_PATH/stats.json" | jq ".\"${md_name}.md\".locales" > "$MODULE_PATH/public/legal-docs/${md_name}_locales.json" +} - echo "[$(echo "${results[@]}" | tr ' ' ',' | sed -E 's/([^,]+)/"\1"/g')]" > "$MODULE_PATH/public/legal-docs/${md_name}_locales.json" +copy_json() { + cd "$LOCAL_PATH" + curl https://raw.githubusercontent.com/mozilla/legal-docs/l10n_reference/.github/stats.json > stats.json } SETTINGS="fxa-settings" @@ -149,6 +158,9 @@ SETTINGS="fxa-settings" # Copy .md files into specified packages case "$MODULE" in "$SETTINGS") + mkdir -p "$MODULE/public/legal-docs" + cd "$MODULE/public/legal-docs" + copy_json copy_md "firefox_privacy_notice" # legal/privacy page copy_md "firefox_cloud_services_tos" # legal/terms page ;; diff --git a/package.json b/package.json index c08db7519a..ee03d5c993 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "generate-lockfile": "docker build . -f _dev/docker/ci-lockfile-generator/Dockerfile -t generate-lockfile && docker run generate-lockfile > yarn.lock", "l10n:clone": "_scripts/l10n/clone.sh", "l10n:prime": "_scripts/l10n/prime.sh", - "l10n:bundle": "_scripts/l10n/bundle.sh" + "l10n:bundle": "_scripts/l10n/bundle.sh", + "legal:clone": "_scripts/clone-legal-docs.sh" }, "homepage": "https://github.com/mozilla/fxa", "bugs": { diff --git a/packages/fxa-content-server/server/lib/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js index b663df67d5..a33fb82701 100644 --- a/packages/fxa-content-server/server/lib/beta-settings.js +++ b/packages/fxa-content-server/server/lib/beta-settings.js @@ -108,6 +108,11 @@ function modifyProxyRes(proxyRes, req, res) { }); res.send(new Buffer.from(html)); } else { + // remove transfer-encoding header, a Content-Length header will be added + // automatically, and these two headers are incompatible. If this is not + // set any fetch request will fail with: + // - Parse Error: Content-Length can't be present with Transfer-Encoding + res.set('Transfer-Encoding', ''); res.send(body); } diff --git a/packages/fxa-graphql-api/src/backend/backend.module.ts b/packages/fxa-graphql-api/src/backend/backend.module.ts index 584d8d036a..65aedb2d12 100644 --- a/packages/fxa-graphql-api/src/backend/backend.module.ts +++ b/packages/fxa-graphql-api/src/backend/backend.module.ts @@ -5,9 +5,10 @@ import { Module } from '@nestjs/common'; import { AuthClientFactory, AuthClientService } from './auth-client.service'; import { ProfileClientService } from './profile-client.service'; +import { LegalService } from './legal.service'; @Module({ - providers: [AuthClientFactory, ProfileClientService], - exports: [AuthClientService, ProfileClientService], + providers: [AuthClientFactory, ProfileClientService, LegalService], + exports: [AuthClientService, ProfileClientService, LegalService], }) export class BackendModule {} diff --git a/packages/fxa-graphql-api/src/backend/legal.service.spec.ts b/packages/fxa-graphql-api/src/backend/legal.service.spec.ts new file mode 100644 index 0000000000..1b11376a43 --- /dev/null +++ b/packages/fxa-graphql-api/src/backend/legal.service.spec.ts @@ -0,0 +1,139 @@ +/* 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 { Provider, Res } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { LegalService } from './legal.service'; + +async function mockFetch(url: RequestInfo | URL, _init?: RequestInit) { + const resp = new Response(); + + if (typeof url === 'string') { + if ( + url.includes( + '/settings/legal-docs/firefox_cloud_services_tos_locales.json' + ) + ) { + return { + ...resp, + ok: true, + json: async () => ['en', 'es'], + }; + } + + if (url.includes('en/firefox_cloud_services_tos')) { + return { + ...resp, + ok: true, + text: async () => '# Firefox Cloud Services: Terms of Service', + }; + } + if (url.includes('es/firefox_cloud_services_tos')) { + return { + ...resp, + ok: true, + text: async () => '# Firefox Cloud Services: Condiciones del servicio', + }; + } + } + + return { + ...resp, + ok: true, + text: async () => '', + }; +} + +// jest.mock('node-fetch', () => fetch); + +describe.only('#unit - LegalService', () => { + let service: LegalService; + + beforeEach(async () => { + jest.resetModules(); + + global.fetch = jest.fn(mockFetch); + + const MockConfig: Provider = { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('http://localhost:3030'), + }, + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [MockConfig, LegalService], + }).compile(); + + service = module.get(LegalService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('returns english doc', async () => { + const doc = await service.getDoc('en', 'firefox_cloud_services_tos'); + expect(doc).toBeDefined(); + expect(doc.markdown).toBeDefined(); + expect( + /# Firefox Cloud Services: Terms of Service/.test(doc.markdown) + ).toBeTruthy(); + }); + + it('returns spanish doc', async () => { + const doc = await service.getDoc('es', 'firefox_cloud_services_tos'); + + expect(doc).toBeDefined(); + expect(doc.markdown).toBeDefined(); + expect( + /# Firefox Cloud Services: Condiciones del servicio/.test(doc.markdown) + ).toBeTruthy(); + }); + + it('returns spanish doc for unsupported dialect', async () => { + const doc = await service.getDoc('es-ar', 'firefox_cloud_services_tos'); + + expect(doc).toBeDefined(); + expect(doc.markdown).toBeDefined(); + expect( + /# Firefox Cloud Services: Condiciones del servicio/.test(doc.markdown) + ).toBeTruthy(); + }); + + it('returns empty string for bogus doc', async () => { + const doc = await service.getDoc('en', 'xyz'); + + expect(doc).toBeDefined(); + expect(doc.markdown).toBeDefined(); + expect(doc.markdown).toEqual(''); + }); + + describe('invalid files', () => { + async function test(fileName: string) { + await expect(async () => { + await service.getDoc('en', fileName); + }).rejects.toThrow('Invalid file name'); + } + + it('rejects empty file name', async () => { + await test(''); + }); + + it('rejects relative path like file name', async () => { + await test('../foo.txt'); + }); + + it('rejects absolute path like file name', async () => { + await test('/foo/bar.txt'); + }); + + it('rejects long file name', async () => { + await test('a'.repeat(1000)); + }); + }); +}); diff --git a/packages/fxa-graphql-api/src/backend/legal.service.ts b/packages/fxa-graphql-api/src/backend/legal.service.ts new file mode 100644 index 0000000000..2fa3ad55d7 --- /dev/null +++ b/packages/fxa-graphql-api/src/backend/legal.service.ts @@ -0,0 +1,78 @@ +/* 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 { ConfigService } from '@nestjs/config'; + +import { AppConfig } from '../config'; +import { determineLocale } from 'fxa-shared/l10n/determineLocale'; +import { Injectable } from '@nestjs/common'; + +const LEGAL_DOCS_PATH = '/settings/legal-docs'; + +@Injectable() +export class LegalService { + private settingsUrl: string; + + constructor(configService: ConfigService) { + this.settingsUrl = configService.get( + 'settingsUrl' + ) as AppConfig['settingsUrl']; + } + + public async getDoc(locale: string, file: string) { + if (/^[a-zA-Z-_]{1,500}$/.test(file) === false) { + throw new Error(`Invalid file name`); + } + + // Determine the best local to use, given the available ones + const availableLocales = await this.getAvailableLocales(file); + if (!availableLocales) { + return { markdown: '' }; + } + + locale = determineLocale(locale, availableLocales)?.replace('_', '-'); + + // Try to get the document from the external legal-docs repo. Note that + // since this is an external repo which might be in a transient state some + // fallback logic has been added. For example, de-DE would turn into de if + // the de-DE document did not exist. + let markdown = await this.tryGetDoc(locale, file); + if (!markdown && locale !== locale.replace('-.*', '')) { + markdown = await this.tryGetDoc(locale.replace('-.*', ''), file); + } + if (!markdown && locale !== 'en') { + markdown = await this.tryGetDoc('en', file); + } + + return { markdown }; + } + + private async tryGetDoc(locale: string, file: string) { + const url = `${this.settingsUrl}${LEGAL_DOCS_PATH}/${locale}/${file}.md`; + const resp = await fetch(url); + const resText = await resp.text(); + + // The response should be markdown. Html is returned if the file is not found. + if (resText.includes('')) { + return ''; + } + return resText; + } + + private async getAvailableLocales(file: string) { + const url = `${this.settingsUrl}${LEGAL_DOCS_PATH}/${file}_locales.json`; + const response = await fetch(`${url}`); + + if (!response.ok) { + throw new Error(response.statusText); + } + + try { + const availableLocales = await response.json(); + return availableLocales as string[]; + } catch { + return ''; + } + } +} diff --git a/packages/fxa-graphql-api/src/config.ts b/packages/fxa-graphql-api/src/config.ts index c638c61c95..34952077a3 100644 --- a/packages/fxa-graphql-api/src/config.ts +++ b/packages/fxa-graphql-api/src/config.ts @@ -58,6 +58,11 @@ const conf = convict({ default: 'http://localhost:7000', env: 'CUSTOMS_SERVER_URL', }, + settingsUrl: { + doc: 'Settings server url', + default: 'http://localhost:3030', + env: 'SETTINGS_SERVER_URL', + }, database: { mysql: { auth: makeMySQLConfig('AUTH', 'fxa'), diff --git a/packages/fxa-graphql-api/src/gql/dto/input/index.ts b/packages/fxa-graphql-api/src/gql/dto/input/index.ts index 834f94b2b1..91a6772907 100644 --- a/packages/fxa-graphql-api/src/gql/dto/input/index.ts +++ b/packages/fxa-graphql-api/src/gql/dto/input/index.ts @@ -26,3 +26,4 @@ export { VerifyTotpInput } from './verify-totp'; export { AccountStatusInput } from './account-status'; export { RecoveryKeyBundleInput } from './recovery-key'; export { PasswordChangeInput } from './password-change'; +export { LegalInput } from './legal'; diff --git a/packages/fxa-graphql-api/src/gql/dto/input/legal.ts b/packages/fxa-graphql-api/src/gql/dto/input/legal.ts new file mode 100644 index 0000000000..409980be1b --- /dev/null +++ b/packages/fxa-graphql-api/src/gql/dto/input/legal.ts @@ -0,0 +1,17 @@ +/* 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 { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class LegalInput { + @Field({ + description: 'The requested l10n locale.', + nullable: true, + }) + public locale!: string; + + @Field({ description: 'The requested legal file.' }) + public file!: string; +} diff --git a/packages/fxa-graphql-api/src/gql/dto/payload/index.ts b/packages/fxa-graphql-api/src/gql/dto/payload/index.ts index 1d659f1e5f..9981c2e665 100644 --- a/packages/fxa-graphql-api/src/gql/dto/payload/index.ts +++ b/packages/fxa-graphql-api/src/gql/dto/payload/index.ts @@ -18,3 +18,4 @@ export { VerifyTotpPayload } from './verify-totp'; export { AccountStatusPayload } from './account-status'; export { RecoveryKeyBundlePayload } from './recovery-key'; export { PasswordChangePayload } from './password-change'; +export { LegalDoc } from './legal'; diff --git a/packages/fxa-graphql-api/src/gql/dto/payload/legal.ts b/packages/fxa-graphql-api/src/gql/dto/payload/legal.ts new file mode 100644 index 0000000000..2897d9b6b8 --- /dev/null +++ b/packages/fxa-graphql-api/src/gql/dto/payload/legal.ts @@ -0,0 +1,11 @@ +/* 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 { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class LegalDoc { + @Field({ description: 'Document in markdown format' }) + public markdown!: string; +} diff --git a/packages/fxa-graphql-api/src/gql/gql.module.ts b/packages/fxa-graphql-api/src/gql/gql.module.ts index cfae42bac9..4e82402561 100644 --- a/packages/fxa-graphql-api/src/gql/gql.module.ts +++ b/packages/fxa-graphql-api/src/gql/gql.module.ts @@ -24,6 +24,7 @@ import { BackendModule } from '../backend/backend.module'; import Config, { AppConfig } from '../config'; import { AccountResolver } from './account.resolver'; import { SessionResolver } from './session.resolver'; +import { LegalResolver } from './legal.resolver'; import { Request, Response } from 'express'; const config = Config.getProperties(); @@ -65,15 +66,19 @@ export const GraphQLConfigFactory = async ( @Module({ imports: [BackendModule, CustomsModule], - providers: [AccountResolver, CustomsService, SessionResolver], + providers: [AccountResolver, CustomsService, SessionResolver, LegalResolver], }) export class GqlModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer .apply((req: Request, res: Response, next: Function) => { - if (config.env !== 'development' && !req.is('application/json') && !req.is('multipart/form-data')) { + if ( + config.env !== 'development' && + !req.is('application/json') && + !req.is('multipart/form-data') + ) { return next( - new HttpException('Request content type is not supported.', 415), + new HttpException('Request content type is not supported.', 415) ); } next(); diff --git a/packages/fxa-graphql-api/src/gql/legal.resolver.spec.ts b/packages/fxa-graphql-api/src/gql/legal.resolver.spec.ts new file mode 100644 index 0000000000..2cc17765ac --- /dev/null +++ b/packages/fxa-graphql-api/src/gql/legal.resolver.spec.ts @@ -0,0 +1,35 @@ +/* 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 { Test, TestingModule } from '@nestjs/testing'; + +import { LegalService } from '../backend/legal.service'; +import { LegalResolver } from './legal.resolver'; + +describe('#unit - LegalResolver', () => { + let resolver: LegalResolver; + let mockLegalService: Partial; + + beforeEach(async () => { + mockLegalService = { + getDoc: jest.fn().mockReturnValue('[]'), + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LegalResolver, + { provide: LegalService, useValue: mockLegalService }, + ], + }).compile(); + + resolver = module.get(LegalResolver); + }); + + it('should be defined', () => { + expect(resolver).toBeDefined(); + }); + + it('calls legal service', async () => { + await resolver.getLegalDoc({ locale: 'en', file: 'foo' }); + expect(mockLegalService.getDoc).toBeCalledWith('en', 'foo'); + }); +}); diff --git a/packages/fxa-graphql-api/src/gql/legal.resolver.ts b/packages/fxa-graphql-api/src/gql/legal.resolver.ts new file mode 100644 index 0000000000..a976f75ec5 --- /dev/null +++ b/packages/fxa-graphql-api/src/gql/legal.resolver.ts @@ -0,0 +1,20 @@ +/* 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 { Args, Query, Resolver } from '@nestjs/graphql'; +import { LegalInput } from './dto/input'; +import { LegalDoc } from './dto/payload'; +import { LegalService } from '../backend/legal.service'; + +@Resolver() +export class LegalResolver { + constructor(private legalService: LegalService) {} + + @Query((returns) => LegalDoc) + public async getLegalDoc( + @Args('input', { type: () => LegalInput }) + input: LegalInput + ) { + return this.legalService.getDoc(input.locale, input.file); + } +} diff --git a/packages/fxa-graphql-api/src/gql/model/legal.ts b/packages/fxa-graphql-api/src/gql/model/legal.ts new file mode 100644 index 0000000000..5238e43a6c --- /dev/null +++ b/packages/fxa-graphql-api/src/gql/model/legal.ts @@ -0,0 +1,8 @@ +/* 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 { ObjectType } from '@nestjs/graphql'; + +@ObjectType({ description: 'Session status' }) +export class Legal {} diff --git a/packages/fxa-settings/package.json b/packages/fxa-settings/package.json index 8d9ae67088..89891b2777 100644 --- a/packages/fxa-settings/package.json +++ b/packages/fxa-settings/package.json @@ -4,27 +4,27 @@ "homepage": "https://accounts.firefox.com/settings", "private": true, "scripts": { - "postinstall": "../../_scripts/clone-legal-docs.sh fxa-settings", "build-css": "tailwindcss -i ./src/styles/tailwind.css -o ./src/styles/tailwind.out.css --postcss", - "build-storybook": "NODE_ENV=production STORYBOOK_BUILD=1 yarn build-css && NODE_OPTIONS=--openssl-legacy-provider build-storybook", - "build": " tsc --build ../fxa-react && NODE_ENV=production yarn build-css && yarn merge-ftl && SKIP_PREFLIGHT_CHECK=true INLINE_RUNTIME_CHUNK=false NODE_OPTIONS=--openssl-legacy-provider rescripts build", + "build-storybook": "NODE_ENV=production STORYBOOK_BUILD=1 yarn build-css && yarn legal-clone && NODE_OPTIONS=--openssl-legacy-provider build-storybook", + "build": "tsc --build ../fxa-react && NODE_ENV=production yarn build-css && yarn legal-clone && yarn merge-ftl && SKIP_PREFLIGHT_CHECK=true INLINE_RUNTIME_CHUNK=false NODE_OPTIONS=--openssl-legacy-provider rescripts build", "compile": "tsc --noEmit", "clean": "git clean -fXd", "eject": "react-scripts eject", "l10n-prime": "yarn l10n:prime fxa-settings", "l10n-bundle": "yarn l10n:bundle fxa-settings branding,react,settings", + "legal-clone": "yarn legal:clone fxa-settings", "lint:eslint": "eslint . .storybook", "lint": "npm-run-all --parallel lint:eslint", "restart": "npm run build-css && pm2 restart pm2.config.js", "start": "yarn merge-ftl && npm run build-css && pm2 start pm2.config.js && ../../_scripts/check-url.sh localhost:3000/settings/static/js/bundle.js", "stop": "pm2 stop pm2.config.js", "delete": "pm2 delete pm2.config.js", - "storybook": "STORYBOOK_BUILD=1 npm run build-css && NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6008 --no-version-updates", - "test": "yarn merge-ftl:test && SKIP_PREFLIGHT_CHECK=true rescripts test --watchAll=false", - "test:watch": "yarn merge-ftl:test && SKIP_PREFLIGHT_CHECK=true rescripts test", - "test:coverage": "yarn test --coverage --watchAll=false", + "storybook": "yarn legal-clone && STORYBOOK_BUILD=1 npm run build-css && NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6008 --no-version-updates", + "test": "yarn legal-clone && yarn merge-ftl:test && SKIP_PREFLIGHT_CHECK=true rescripts test --watchAll=false", + "test:watch": "yarn legal-clone && yarn merge-ftl:test && SKIP_PREFLIGHT_CHECK=true rescripts test", + "test:coverage": "yarn legal-clone && yarn test --coverage --watchAll=false", "test:unit": "echo No unit tests present for $npm_package_name", - "test:integration": "yarn merge-ftl:test && tsc --build ../fxa-react && JEST_JUNIT_OUTPUT_FILE=../../artifacts/tests/$npm_package_name/jest-integration.xml SKIP_PREFLIGHT_CHECK=true rescripts test --watchAll=false --ci --runInBand --reporters=default --reporters=jest-junit", + "test:integration": "yarn legal-clone && yarn merge-ftl:test && tsc --build ../fxa-react && JEST_JUNIT_OUTPUT_FILE=../../artifacts/tests/$npm_package_name/jest-integration.xml SKIP_PREFLIGHT_CHECK=true rescripts test --watchAll=false --ci --runInBand --reporters=default --reporters=jest-junit", "merge-ftl": "yarn l10n-prime && grunt merge-ftl && yarn l10n-bundle", "merge-ftl:test": "yarn l10n-prime && grunt merge-ftl:test", "watch-ftl": "grunt watch-ftl" diff --git a/packages/fxa-settings/src/components/LegalWithMarkdown/index.tsx b/packages/fxa-settings/src/components/LegalWithMarkdown/index.tsx index 296e76662e..98bc72767c 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, { useEffect, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import AppLayout from '../AppLayout'; import { navigate } from '@reach/router'; import { FtlMsg } from 'fxa-react/lib/utils'; @@ -13,6 +13,21 @@ import Banner, { BannerType } from '../Banner'; import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; import { REACT_ENTRYPOINT } from '../../constants'; import { fetchLegalMd, LegalDocFile } from '../../lib/file-utils-legal'; +import { AppContext } from '../../models'; + +export type FetchLegalDoc = ( + locale: string, + legalDocFile: string +) => Promise<{ markdown?: string; error?: string }>; + +export type LegalWithMarkdownProps = { + locale?: string; + viewName: 'legal-privacy' | 'legal-terms'; + legalDocFile: LegalDocFile; + headingTextFtlId: string; + headingText: string; + fetchLegalDoc?: FetchLegalDoc; +}; const LegalWithMarkdown = ({ locale, @@ -20,23 +35,25 @@ const LegalWithMarkdown = ({ legalDocFile, headingTextFtlId, headingText, -}: { - locale?: string; - viewName: 'legal-privacy' | 'legal-terms'; - legalDocFile: LegalDocFile; - headingTextFtlId: string; - headingText: string; -}) => { + fetchLegalDoc, +}: LegalWithMarkdownProps) => { usePageViewEvent(viewName, REACT_ENTRYPOINT); const [markdown, setMarkdown] = useState(); const [error, setError] = useState(); + const { apolloClient } = useContext(AppContext); useEffect(() => { let isMounted = true; (async () => { - const { markdown: fetchedMarkdown, error } = await fetchLegalMd( - navigator.languages, - locale, + async function fetchLegal(locale: string, legalDocFile: string) { + if (fetchLegalDoc != null) { + return fetchLegalDoc(locale, legalDocFile); + } + return fetchLegalMd(apolloClient, locale, legalDocFile); + } + + const { markdown: fetchedMarkdown, error } = await fetchLegal( + locale || navigator.language || 'en', legalDocFile ); // ensure component is still mounted before trying to render (fixes state update warning) @@ -52,7 +69,7 @@ const LegalWithMarkdown = ({ return () => { isMounted = false; }; - }, [locale, legalDocFile]); + }, [locale, legalDocFile, apolloClient, fetchLegalDoc]); const buttonHandler = () => { logViewEvent(`flow.${viewName}`, 'back', REACT_ENTRYPOINT); diff --git a/packages/fxa-settings/src/lib/file-utils-legal.tsx b/packages/fxa-settings/src/lib/file-utils-legal.tsx index d0100fe948..eb635fc09b 100644 --- a/packages/fxa-settings/src/lib/file-utils-legal.tsx +++ b/packages/fxa-settings/src/lib/file-utils-legal.tsx @@ -2,74 +2,48 @@ * 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 { determineLocale } from 'fxa-shared/l10n/determineLocale'; -import sentryMetrics from 'fxa-shared/lib/sentry'; +import { GET_LEGAL_DOC } from '../models'; +import { ApolloClient, gql } from '@apollo/client'; export enum LegalDocFile { privacy = 'firefox_privacy_notice', terms = 'firefox_cloud_services_tos', } -const LEGAL_DOCS_PATH = '/settings/legal-docs'; - -// TODO: probably move this + clone script to gql-api to reduce network requests - -const fetchLegalMdByLocale = async (locale: string, file: LegalDocFile) => { - // use the general app error for now, might change when moving this to gql-api - const error = 'Something went wrong. Please try again later.'; - - try { - const response = await fetch(`${LEGAL_DOCS_PATH}/${locale}/${file}.md`); - if (response.ok) { - const resText = await response.text(); - if (resText.includes('')) { - return { error }; - } - return { markdown: resText }; - } else { - throw Error(response.statusText); - } - } catch (e) { - sentryMetrics.captureException(e); - - // TODO: If the first preferred language can't be loaded, try - // the next 2? and then fallback to English + clean this up - if (locale !== 'en') { - try { - const response = await fetch(`${LEGAL_DOCS_PATH}/en/${file}.md`); - if (response.ok) { - const resText = await response.text(); - if (resText.includes('')) { - return { error }; - } - return { markdown: resText }; - } - } catch (e) { - sentryMetrics.captureException(e); - return { error }; - } - } - return { error }; - } -}; - export const fetchLegalMd = async ( - acceptLanguages: readonly string[], - localeParam: string | undefined, - file: LegalDocFile -) => { - let availableLocales; - try { - const response = await fetch(`${LEGAL_DOCS_PATH}/${file}_locales.json`); - availableLocales = await response.json(); - } catch (e) { - // report to Sentry and allow default locales to be loaded - sentryMetrics.captureException(e); + apolloClient: ApolloClient | undefined, + locale: string, + file: string +): Promise<{ + markdown?: string; + error?: string; +}> => { + const error = `Something went wrong. Try again later.`; + + if (apolloClient == null) { + console.error('No apolloClient provided.'); + return { + error, + }; } - const locale = determineLocale( - localeParam ? localeParam : acceptLanguages.join(', '), - availableLocales - ); - return fetchLegalMdByLocale(locale, file); + try { + const result = await apolloClient.query({ + query: GET_LEGAL_DOC, + variables: { input: { locale, file } }, + }); + + if (result?.data?.getLegalDoc?.markdown) { + return { + markdown: result.data.getLegalDoc?.markdown, + }; + } + + // If the markdown we got back is empty / invalid error out. + throw new Error(error); + } catch (err) { + return { + error, + }; + } }; diff --git a/packages/fxa-settings/src/models/Legal.ts b/packages/fxa-settings/src/models/Legal.ts new file mode 100644 index 0000000000..9b7688f4de --- /dev/null +++ b/packages/fxa-settings/src/models/Legal.ts @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { gql } from '@apollo/client'; + +export interface LegalDoc { + markdown: string; +} + +export const GET_LEGAL_DOC = gql` + query GetLegalDoc($input: LegalInput!) { + getLegalDoc(input: $input) { + markdown + } + } +`; diff --git a/packages/fxa-settings/src/models/index.ts b/packages/fxa-settings/src/models/index.ts index a9a9df3f14..b4fd32b99a 100644 --- a/packages/fxa-settings/src/models/index.ts +++ b/packages/fxa-settings/src/models/index.ts @@ -1,3 +1,7 @@ +/* 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/. */ + export * from './AppContext'; export * from './AlertBarInfo'; export * from './Account'; @@ -5,3 +9,4 @@ export * from './Session'; export * from './hooks'; export * from './verification'; export * from './reliers'; +export * from './Legal'; diff --git a/packages/fxa-settings/src/pages/Legal/Privacy/index.stories.tsx b/packages/fxa-settings/src/pages/Legal/Privacy/index.stories.tsx index 3e35341eb5..fc251a4345 100644 --- a/packages/fxa-settings/src/pages/Legal/Privacy/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Legal/Privacy/index.stories.tsx @@ -6,6 +6,7 @@ import React from 'react'; import LegalPrivacy from '.'; import { Meta } from '@storybook/react'; import { withLocalization } from '../../../../.storybook/decorators'; +import { fetchLegalDoc } from '../mocks'; export default { title: 'Pages/Legal/Privacy', @@ -13,4 +14,4 @@ export default { decorators: [withLocalization], } as Meta; -export const Basic = () => ; +export const Basic = () => ; diff --git a/packages/fxa-settings/src/pages/Legal/Privacy/index.tsx b/packages/fxa-settings/src/pages/Legal/Privacy/index.tsx index f202fc8652..b7e05d5b67 100644 --- a/packages/fxa-settings/src/pages/Legal/Privacy/index.tsx +++ b/packages/fxa-settings/src/pages/Legal/Privacy/index.tsx @@ -4,20 +4,30 @@ import React from 'react'; import { RouteComponentProps } from '@reach/router'; -import LegalWithMarkdown from '../../../components/LegalWithMarkdown'; +import LegalWithMarkdown, { + FetchLegalDoc, +} from '../../../components/LegalWithMarkdown'; import { LegalDocFile } from '../../../lib/file-utils-legal'; export const viewName = 'legal-privacy'; +export type LegalPrivacyProps = { + locale?: string; + fetchLegalDoc?: FetchLegalDoc; +}; + const LegalPrivacy = ({ locale, -}: { locale?: string } & RouteComponentProps) => ( - -); + fetchLegalDoc, +}: LegalPrivacyProps & RouteComponentProps) => { + return ( + + ); +}; export default LegalPrivacy; diff --git a/packages/fxa-settings/src/pages/Legal/Terms/index.stories.tsx b/packages/fxa-settings/src/pages/Legal/Terms/index.stories.tsx index 856073d7af..30c8e78b94 100644 --- a/packages/fxa-settings/src/pages/Legal/Terms/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Legal/Terms/index.stories.tsx @@ -6,6 +6,7 @@ import React from 'react'; import LegalTerms from '.'; import { Meta } from '@storybook/react'; import { withLocalization } from '../../../../.storybook/decorators'; +import { fetchLegalDoc } from '../mocks'; export default { title: 'Pages/Legal/Terms', @@ -13,4 +14,4 @@ export default { decorators: [withLocalization], } as Meta; -export const Basic = () => ; +export const Basic = () => ; diff --git a/packages/fxa-settings/src/pages/Legal/Terms/index.tsx b/packages/fxa-settings/src/pages/Legal/Terms/index.tsx index a52f644398..bee8da9d7c 100644 --- a/packages/fxa-settings/src/pages/Legal/Terms/index.tsx +++ b/packages/fxa-settings/src/pages/Legal/Terms/index.tsx @@ -4,14 +4,24 @@ import React from 'react'; import { RouteComponentProps } from '@reach/router'; -import LegalWithMarkdown from '../../../components/LegalWithMarkdown'; +import LegalWithMarkdown, { + FetchLegalDoc, +} from '../../../components/LegalWithMarkdown'; import { LegalDocFile } from '../../../lib/file-utils-legal'; export const viewName = 'legal-terms'; -const LegalTerms = ({ locale }: { locale?: string } & RouteComponentProps) => ( +export type LegalTermsProps = { + locale?: string; + fetchLegalDoc?: FetchLegalDoc; +}; + +const LegalTerms = ({ + locale, + fetchLegalDoc, +}: LegalTermsProps & RouteComponentProps) => ( { + for (const fallback of [locale, locale.replace(/-.*/, ''), 'en']) { + let markdown = await fetchDoc(fallback, legalDocFile); + + // We can assume that the first line of the markdown file is a heading. + if (markdown.trim().startsWith('#')) { + return { markdown }; + } + } + return { error: 'Not found' }; +}; + +async function fetchDoc(locale: string, legalDocFile: string) { + const path = `/legal-docs/${locale}/${legalDocFile}.md`; + const response = await fetch(path); + if (response.ok) { + return await response.text(); + } + return ''; +}