From 2fb70a68869f223d035cb21681c692583f26fdc0 Mon Sep 17 00:00:00 2001 From: Reino Muhl Date: Wed, 1 May 2024 10:25:35 -0400 Subject: [PATCH] feat(next): add payment element and submit Because: * Add the Stripe Payment Element to the checkout page * Be able to checkout and successfully add a new payment method and new subscription. This commit: * Adds the PaymentElement to the StripeWrapper * Adds the Subscribe Now button * Adds the Full Name field * Updates the NestApp with Firestore and StatsD provider factories Closes #FXA-8850 and #FXA-7810 --- apps/payments/next/.env.development | 17 +- apps/payments/next/.env.production | 13 ++ .../[interval]/[cartId]/start/page.tsx | 84 ++------ apps/payments/next/app/page.tsx | 16 +- apps/payments/next/app/styles/global.css | 2 +- apps/payments/next/middleware.ts | 6 +- apps/payments/next/tailwind.config.ts | 11 ++ .../src/lib/capability.manager.spec.ts | 4 +- .../cart/src/lib/cart.service.spec.ts | 4 +- libs/payments/cart/src/lib/cart.service.ts | 2 +- libs/payments/cart/src/lib/cart.utils.spec.ts | 8 +- libs/payments/cart/src/lib/cart.utils.ts | 4 +- .../src/lib/eligibility.service.spec.ts | 4 +- .../ui/src/lib/actions/handleStripeError.ts | 4 +- .../lib/client/components/CheckoutForm.tsx | 179 ++++++++++++++++++ .../lib/client/components/StripeWrapper.tsx | 36 +++- .../payments/ui/src/lib/nestapp/app.module.ts | 14 +- libs/payments/ui/src/lib/nestapp/config.ts | 12 ++ libs/shared/assets/src/images/lock.svg | 3 + .../contentful/src/lib/contentful.client.ts | 30 +-- .../db/firestore/src/lib/firestore.config.ts | 28 +++ .../firestore/src/lib/firestore.provider.ts | 37 +++- .../metrics/statsd/src/lib/statsd.config.ts | 26 +++ .../statsd/src/lib/statsd.provider.spec.ts | 6 +- .../metrics/statsd/src/lib/statsd.provider.ts | 39 ++-- package.json | 1 + packages/fxa-graphql-api/src/app.module.ts | 4 +- .../src/database/database.module.ts | 4 +- .../fxa-graphql-api/src/gql/gql.module.ts | 4 +- yarn.lock | 46 +++++ 30 files changed, 489 insertions(+), 159 deletions(-) create mode 100644 libs/payments/ui/src/lib/client/components/CheckoutForm.tsx create mode 100644 libs/shared/assets/src/images/lock.svg create mode 100644 libs/shared/db/firestore/src/lib/firestore.config.ts create mode 100644 libs/shared/metrics/statsd/src/lib/statsd.config.ts diff --git a/apps/payments/next/.env.development b/apps/payments/next/.env.development index 56e7e063c6..8bb06e2cdd 100644 --- a/apps/payments/next/.env.development +++ b/apps/payments/next/.env.development @@ -25,8 +25,8 @@ MYSQL_CONFIG__ACQUIRE_TIMEOUT_MILLIS= GEODB_CONFIG__DB_PATH=../../../libs/shared/geodb/db/cities-db.mmdb # GeoDBManagerConfig -GEODB_MANAGER_CONFIG__LOCATION_OVERRIDE__COUNTRY_CODE= -GEODB_MANAGER_CONFIG__LOCATION_OVERRIDE__POSTAL_CODE= +GEODB_MANAGER_CONFIG__LOCATION_OVERRIDE__COUNTRY_CODE=US +GEODB_MANAGER_CONFIG__LOCATION_OVERRIDE__POSTAL_CODE=11211 # StripeConfig STRIPE_CONFIG__API_KEY=11233 @@ -50,3 +50,16 @@ CONTENTFUL_CLIENT_CONFIG__GRAPHQL_ENVIRONMENT=dev CONTENTFUL_CLIENT_CONFIG__FIRESTORE_CACHE_COLLECTION_NAME=contentfulQueryCacheCollection CONTENTFUL_CLIENT_CONFIG__MEM_CACHE_T_T_L= CONTENTFUL_CLIENT_CONFIG__FIRESTORE_CACHE_T_T_L= + +# Firestore Config +FIRESTORE_CONFIG__CREDENTIALS__CLIENT_EMAIL= +FIRESTORE_CONFIG__CREDENTIALS__PRIVATE_KEY= +FIRESTORE_CONFIG__KEY_FILENAME= +FIRESTORE_CONFIG__PROJECT_ID= + +# StatsD Config +STATS_D_CONFIG__SAMPLE_RATE= +STATS_D_CONFIG__MAX_BUFFER_SIZE= +STATS_D_CONFIG__HOST= +STATS_D_CONFIG__PORT= +STATS_D_CONFIG__PREFIX= diff --git a/apps/payments/next/.env.production b/apps/payments/next/.env.production index d56b0b1780..c7a69b7cb7 100644 --- a/apps/payments/next/.env.production +++ b/apps/payments/next/.env.production @@ -44,3 +44,16 @@ CONTENTFUL_CLIENT_CONFIG__GRAPHQL_ENVIRONMENT=master CONTENTFUL_CLIENT_CONFIG__FIRESTORE_CACHE_COLLECTION_NAME=contentfulQueryCacheCollection CONTENTFUL_CLIENT_CONFIG__MEM_CACHE_T_T_L= 300 CONTENTFUL_CLIENT_CONFIG__FIRESTORE_CACHE_T_T_L= 604800 + +# Firestore Config +FIRESTORE_CONFIG__CREDENTIALS__CLIENT_EMAIL= +FIRESTORE_CONFIG__CREDENTIALS__PRIVATE_KEY= +FIRESTORE_CONFIG__KEY_FILENAME= +FIRESTORE_CONFIG__PROJECT_ID= + +# StatsD Config +STATS_D_CONFIG__SAMPLE_RATE= +STATS_D_CONFIG__MAX_BUFFER_SIZE= +STATS_D_CONFIG__HOST= +STATS_D_CONFIG__PORT= +STATS_D_CONFIG__PREFIX= diff --git a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx index e6a54ff55a..e92a68b882 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx @@ -1,18 +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 Stripe from 'stripe'; import { app, - handleStripeErrorAction, getCartOrRedirectAction, SupportedPages, } from '@fxa/payments/ui/server'; import { DEFAULT_LOCALE } from '@fxa/shared/l10n'; -import { auth, signIn, signOut } from 'apps/payments/next/auth'; +import { auth, signIn } from 'apps/payments/next/auth'; import { headers } from 'next/headers'; import { CheckoutParams } from '../layout'; import { StripeWrapper } from '@fxa/payments/ui'; +import { getFakeCartData } from 'apps/payments/next/app/_lib/apiClient'; export const dynamic = 'force-dynamic'; @@ -30,23 +29,15 @@ export default async function Checkout({ params }: { params: CheckoutParams }) { params.cartId, SupportedPages.START ); - const [session, l10n, cart] = await Promise.all([ + //TODO - Replace with cartPromise as part of FXA-8903 + const fakeCartDataPromise = getFakeCartData(params.cartId); + const [session, l10n, cart, fakeCart] = await Promise.all([ sessionPromise, l10nPromise, cartPromise, + fakeCartDataPromise, ]); - // Temporary function used to test handleStripeErrorAction - // This is to be deleted as part of FXA-8850 - async function onSubmit() { - 'use server'; - const error: Stripe.StripeRawError = new Error( - 'Simulated Stripe Error' - ) as unknown as Stripe.StripeRawError; - error.type = 'card_error'; - await handleStripeErrorAction(cart.id, cart.version, error); - } - return ( <>
- - {/* - Temporary function used to test handleStripeErrorAction - This is to be deleted as part of FXA-8850 - */} -
-
- - -
-
- {/* - Temporary section to test NextAuth prompt/no prompt signin - To be deleted as part of FXA-7521/FXA-7523 if not sooner where necessary - */} - {!session ? ( -
-
{ - 'use server'; - await signIn('fxa'); - }} - > - -
-
{ - 'use server'; - await signIn('fxa', undefined, { prompt: 'none' }); - }} - > - -
-
- ) : ( -
-

Hello {session?.user?.id}

-
{ - 'use server'; - await signOut(); - }} - > - -
-
- )} + ); diff --git a/apps/payments/next/app/page.tsx b/apps/payments/next/app/page.tsx index 22107713e3..d8affad9ae 100644 --- a/apps/payments/next/app/page.tsx +++ b/apps/payments/next/app/page.tsx @@ -17,19 +17,19 @@ export default function Index() {

With auth

-

123Done - Monthly

+

VPN - Monthly

Redirect
-

123Done - Yearly

+

VPN - Yearly

Redirect @@ -38,19 +38,19 @@ export default function Index() {

Without auth

-

123Done - Monthly

+

VPN - Monthly

Redirect
-

123Done - Yearly

+

VPN - Yearly

Redirect diff --git a/apps/payments/next/app/styles/global.css b/apps/payments/next/app/styles/global.css index 22028f8155..d76e880e3a 100644 --- a/apps/payments/next/app/styles/global.css +++ b/apps/payments/next/app/styles/global.css @@ -6,7 +6,7 @@ /* layout */ body { - @apply grid min-h-[100vh] tablet:justify-center text-base font-body bg-grey-10 text-grey-900; + @apply grid min-h-[100vh] tablet:justify-center text-base font-body bg-grey-10 text-grey-900 leading-[1.15]; } /* Purchase Details, offeringId */ diff --git a/apps/payments/next/middleware.ts b/apps/payments/next/middleware.ts index 64fc283dc1..2aee188708 100644 --- a/apps/payments/next/middleware.ts +++ b/apps/payments/next/middleware.ts @@ -7,11 +7,11 @@ export function middleware(request: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); const cspHeader = ` default-src 'self'; - connect-src 'self' 'https://api.stripe.com'; - frame-src 'https://js.stripe.com' 'https://hooks.stripe.com'; + connect-src 'self' https://api.stripe.com; + frame-src https://js.stripe.com https://hooks.stripe.com; script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https: http: 'unsafe-inline' ${ process.env.NODE_ENV === 'production' ? '' : `'unsafe-eval'` - } 'https://js.stripe.com'; + } https://js.stripe.com; style-src 'self' 'nonce-${nonce}'; img-src 'self' blob: data:; font-src 'self'; diff --git a/apps/payments/next/tailwind.config.ts b/apps/payments/next/tailwind.config.ts index 3411ff7ee6..da61321549 100644 --- a/apps/payments/next/tailwind.config.ts +++ b/apps/payments/next/tailwind.config.ts @@ -15,4 +15,15 @@ export default >{ ), ...createGlobPatternsForDependencies(__dirname), ], + theme: { + extend: { + boxShadow: { + inputError: + '0 1px 2px rgba(0, 0, 0, 0.3), 0 3px 6px rgba(0, 0, 0, 0.02), 0 0 0 1px #df1b41', + }, + colors: { + 'alert-red': '#D70022', + }, + }, + }, }; diff --git a/libs/payments/capability/src/lib/capability.manager.spec.ts b/libs/payments/capability/src/lib/capability.manager.spec.ts index 344005104d..6145d36f62 100644 --- a/libs/payments/capability/src/lib/capability.manager.spec.ts +++ b/libs/payments/capability/src/lib/capability.manager.spec.ts @@ -22,7 +22,7 @@ import { } from '@fxa/shared/contentful'; import { CapabilityManager } from './capability.manager'; import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; -import { MockStatsDFactory } from '@fxa/shared/metrics/statsd'; +import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; describe('CapabilityManager', () => { let capabilityManager: CapabilityManager; @@ -32,7 +32,7 @@ describe('CapabilityManager', () => { const module = await Test.createTestingModule({ providers: [ MockFirestoreProvider, - MockStatsDFactory, + MockStatsDProvider, ContentfulClientConfig, ContentfulClient, ContentfulManager, diff --git a/libs/payments/cart/src/lib/cart.service.spec.ts b/libs/payments/cart/src/lib/cart.service.spec.ts index d95fb9c08f..a081f7958c 100644 --- a/libs/payments/cart/src/lib/cart.service.spec.ts +++ b/libs/payments/cart/src/lib/cart.service.spec.ts @@ -43,7 +43,7 @@ import { ContentfulClientConfig, ContentfulManager, } from '@fxa/shared/contentful'; -import { MockStatsDFactory } from '@fxa/shared/metrics/statsd'; +import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; import { ConfigService } from '@nestjs/config'; import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; import { @@ -70,7 +70,7 @@ describe('CartService', () => { AccountCustomerManager, ConfigService, MockFirestoreProvider, - MockStatsDFactory, + MockStatsDProvider, ContentfulClientConfig, ContentfulClient, ContentfulManager, diff --git a/libs/payments/cart/src/lib/cart.service.ts b/libs/payments/cart/src/lib/cart.service.ts index a09b3f3bcf..64416fc80b 100644 --- a/libs/payments/cart/src/lib/cart.service.ts +++ b/libs/payments/cart/src/lib/cart.service.ts @@ -113,7 +113,7 @@ export class CartService { try { const cart = await this.cartManager.fetchCartById(cartId); - this.checkoutService.payWithStripe(cart, paymentMethodId); + await this.checkoutService.payWithStripe(cart, paymentMethodId); await this.cartManager.finishCart(cartId, version, {}); } catch (e) { diff --git a/libs/payments/cart/src/lib/cart.utils.spec.ts b/libs/payments/cart/src/lib/cart.utils.spec.ts index 08f4a11be2..87e43e39e2 100644 --- a/libs/payments/cart/src/lib/cart.utils.spec.ts +++ b/libs/payments/cart/src/lib/cart.utils.spec.ts @@ -1,13 +1,13 @@ -import Stripe from 'stripe'; +import { StripeError } from '@stripe/stripe-js'; import { stripeErrorToErrorReasonId } from './cart.utils'; import { CartErrorReasonId } from '@fxa/shared/db/mysql/account'; describe('utils', () => { describe('stripeErrorReasonId', () => { - let mockStripeError: Stripe.StripeRawError; + let mockStripeError: StripeError; beforeEach(() => { - mockStripeError = new Error() as unknown as Stripe.StripeRawError; + mockStripeError = new Error() as unknown as StripeError; }); it('should return for type card_error', () => { @@ -17,7 +17,7 @@ describe('utils', () => { }); it('should return for default', () => { - mockStripeError.type = 'invalid_grant'; + mockStripeError.type = 'api_error'; const result = stripeErrorToErrorReasonId(mockStripeError); expect(result).toBe(CartErrorReasonId.Unknown); }); diff --git a/libs/payments/cart/src/lib/cart.utils.ts b/libs/payments/cart/src/lib/cart.utils.ts index 22ee60a632..53cd0a17b5 100644 --- a/libs/payments/cart/src/lib/cart.utils.ts +++ b/libs/payments/cart/src/lib/cart.utils.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Stripe } from 'stripe'; +import { StripeError } from '@stripe/stripe-js'; import { EligibilityStatus } from '@fxa/payments/eligibility'; import { CartEligibilityStatus, @@ -18,7 +18,7 @@ export const handleEligibilityStatusMap = { }; export function stripeErrorToErrorReasonId( - stripeError: Stripe.StripeRawError + stripeError: StripeError ): CartErrorReasonId { switch (stripeError.type) { case 'card_error': diff --git a/libs/payments/eligibility/src/lib/eligibility.service.spec.ts b/libs/payments/eligibility/src/lib/eligibility.service.spec.ts index 7b3efb8682..02e9669afd 100644 --- a/libs/payments/eligibility/src/lib/eligibility.service.spec.ts +++ b/libs/payments/eligibility/src/lib/eligibility.service.spec.ts @@ -33,7 +33,7 @@ import { OfferingComparison, OfferingOverlapProductResult, } from './eligibility.types'; -import { MockStatsDFactory } from '@fxa/shared/metrics/statsd'; +import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; describe('EligibilityService', () => { @@ -52,7 +52,7 @@ describe('EligibilityService', () => { MockFirestoreProvider, ContentfulClientConfig, ContentfulClient, - MockStatsDFactory, + MockStatsDProvider, ContentfulManager, EligibilityManager, StripeConfig, diff --git a/libs/payments/ui/src/lib/actions/handleStripeError.ts b/libs/payments/ui/src/lib/actions/handleStripeError.ts index 9d173d6636..90e7fc02a9 100644 --- a/libs/payments/ui/src/lib/actions/handleStripeError.ts +++ b/libs/payments/ui/src/lib/actions/handleStripeError.ts @@ -4,7 +4,7 @@ 'use server'; -import Stripe from 'stripe'; +import { StripeError } from '@stripe/stripe-js'; import { app } from '../nestapp/app'; import { redirect } from 'next/navigation'; import { stripeErrorToErrorReasonId } from '@fxa/payments/cart'; @@ -12,7 +12,7 @@ import { stripeErrorToErrorReasonId } from '@fxa/payments/cart'; export const handleStripeErrorAction = async ( cartId: string, version: number, - stripeError: Stripe.StripeRawError + stripeError: StripeError ) => { const errorReasonId = stripeErrorToErrorReasonId(stripeError); diff --git a/libs/payments/ui/src/lib/client/components/CheckoutForm.tsx b/libs/payments/ui/src/lib/client/components/CheckoutForm.tsx new file mode 100644 index 0000000000..719d75019d --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/CheckoutForm.tsx @@ -0,0 +1,179 @@ +/* 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/. */ +'use client'; + +import { + PaymentElement, + useStripe, + useElements, +} from '@stripe/react-stripe-js'; +import { StripePaymentElementChangeEvent } from '@stripe/stripe-js'; +import { checkoutCartWithStripe } from '../../actions/checkoutCartWithStripe'; +import { useEffect, useState } from 'react'; +import { handleStripeErrorAction } from '../../actions/handleStripeError'; +import LockImage from '@fxa/shared/assets/images/lock.svg'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import * as Form from '@radix-ui/react-form'; + +interface CheckoutFormProps { + cart: { + id: string; + version: number; + email: string | null; + }; +} + +export function CheckoutForm({ cart }: CheckoutFormProps) { + const router = useRouter(); + const stripe = useStripe(); + const elements = useElements(); + const [isPaymentElementLoading, setIsPaymentElementLoading] = useState(true); + const [loading, setLoading] = useState(false); + const [stripeFieldsComplete, setStripeFieldsComplete] = useState(false); + const [fullName, setFullName] = useState(''); + const [hasFullNameError, setHasFullNameError] = useState(false); + + useEffect(() => { + if (elements) { + const element = elements.getElement('payment'); + if (element) { + element.on('ready', () => { + setIsPaymentElementLoading(false); + }); + + element.on('change', (event: StripePaymentElementChangeEvent) => { + if (event.complete) { + setStripeFieldsComplete(true); + } else { + if (!stripeFieldsComplete) { + setStripeFieldsComplete(false); + } + } + }); + } else { + setIsPaymentElementLoading(false); + } + } + }, [elements, stripeFieldsComplete]); + + const submitHandler = async ( + event: React.SyntheticEvent + ) => { + event.preventDefault(); + + if (!stripe || !elements) { + // Stripe.js hasn't yet loaded. + // Make sure to disable form submission until Stripe.js has loaded. + return; + } + + setLoading(true); + + setHasFullNameError(!fullName); + + // Trigger form validation and wallet collection + const { error: submitError } = await elements.submit(); + if (submitError) { + setLoading(false); + return; + } + + const { paymentMethod, error: methodError } = + await stripe.createPaymentMethod({ + elements, + params: { + billing_details: { + name: fullName, + email: cart.email || '', + }, + }, + }); + + if (methodError || !paymentMethod) { + if (methodError.type === 'validation_error') { + return; + } else { + await handleStripeErrorAction(cart.id, cart.version, methodError); + return; + } + } + + await checkoutCartWithStripe(cart.id, cart.version, paymentMethod.id); + + // TODO - To be added in M3B - Redirect customer to '/processing' page + router.push('./start'); + // TODO - To be moved in M3B - Confirm Payment on '/processing' page + // Confirm the Intent using the details collected by the Payment Element + //const { error } = await stripe.confirmPayment({ + // clientSecret, + // confirmParams: { + // return_url: successRedirectUrl, + // }, + //}); + // + //if (error) { + // // This point is only reached if there's an immediate error when confirming the Intent. + // // Show the error to your customer (for example, "payment details incomplete"). + // await handleStripeErrorAction(cart.id, cart.version, error); + //} else { + // // Your customer is redirected to your `return_url`. For some payment + // // methods like iDEAL, your customer is redirected to an intermediate + // // site first to authorize the payment, then redirected to the `return_url`. + //} + }; + + const nonStripeFieldsComplete = !!fullName; + + return ( + + {!isPaymentElementLoading && ( + + + Name as it appears on your card + + + { + setFullName(e.target.value); + setHasFullNameError(!e.target.value); + }} + /> + + {hasFullNameError && ( + +

+ Please enter your name +

+
+ )} +
+ )} + + {!isPaymentElementLoading && ( + + + + )} +
+ ); +} diff --git a/libs/payments/ui/src/lib/client/components/StripeWrapper.tsx b/libs/payments/ui/src/lib/client/components/StripeWrapper.tsx index b2add17e16..50065e1f1e 100644 --- a/libs/payments/ui/src/lib/client/components/StripeWrapper.tsx +++ b/libs/payments/ui/src/lib/client/components/StripeWrapper.tsx @@ -5,13 +5,19 @@ import { loadStripe, StripeElementsOptions } from '@stripe/stripe-js'; import { Elements } from '@stripe/react-stripe-js'; +import { CheckoutForm } from './CheckoutForm'; interface StripeWrapperProps { amount: number; currency: string; + cart: { + id: string; + version: number; + email: string | null; + }; } -export function StripeWrapper({ amount, currency }: StripeWrapperProps) { +export function StripeWrapper({ amount, currency, cart }: StripeWrapperProps) { // TODO - Load from config const stripePromise = loadStripe( 'pk_test_VNpCidC0a2TJJB3wqXq7drhN00sF8r9mhs' @@ -22,11 +28,37 @@ export function StripeWrapper({ amount, currency }: StripeWrapperProps) { amount, currency, paymentMethodCreation: 'manual', + appearance: { + variables: { + fontFamily: + 'Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif', + fontSizeBase: '16px', + fontWeightNormal: '500', + colorDanger: '#D70022', + }, + rules: { + '.Tab': { + borderColor: 'rgba(0,0,0,0.3)', + }, + '.Input': { + borderColor: 'rgba(0,0,0,0.3)', + }, + '.Input::placeholder': { + color: '#5E5E72', // Matches grey-500 of tailwind.config.js + fontWeight: '400', + }, + '.Label': { + color: '#6D6D6E', // Matches grey-400 of tailwind.config.js + fontWeight: '500', + lineHeight: '1.15', + }, + }, + }, }; return ( - Hello there from Stripe + ); } diff --git a/libs/payments/ui/src/lib/nestapp/app.module.ts b/libs/payments/ui/src/lib/nestapp/app.module.ts index b5c270b710..9189d9cf26 100644 --- a/libs/payments/ui/src/lib/nestapp/app.module.ts +++ b/libs/payments/ui/src/lib/nestapp/app.module.ts @@ -29,8 +29,8 @@ import { PayPalManager, PaypalCustomerManager, } from '@fxa/payments/paypal'; -import { FirestoreService } from '@fxa/shared/db/firestore'; -import { StatsDService } from '@fxa/shared/metrics/statsd'; +import { FirestoreProvider } from '@fxa/shared/db/firestore'; +import { StatsDProvider } from '@fxa/shared/metrics/statsd'; @Module({ imports: [ @@ -44,14 +44,8 @@ import { StatsDService } from '@fxa/shared/metrics/statsd'; ], controllers: [], providers: [ - { - provide: FirestoreService, - useValue: {}, //Temporary value to resolve Payments Next startup issues - }, - { - provide: StatsDService, - useValue: {}, //Temporary value to resolve Payments Next startup issues - }, + FirestoreProvider, + StatsDProvider, LocalizerRscFactoryProvider, NextJSActionsService, AccountDatabaseNestFactory, diff --git a/libs/payments/ui/src/lib/nestapp/config.ts b/libs/payments/ui/src/lib/nestapp/config.ts index 9c2d9e281e..d997e5e52a 100644 --- a/libs/payments/ui/src/lib/nestapp/config.ts +++ b/libs/payments/ui/src/lib/nestapp/config.ts @@ -10,6 +10,8 @@ import { GeoDBConfig, GeoDBManagerConfig } from '@fxa/shared/geodb'; import { PaypalClientConfig } from 'libs/payments/paypal/src/lib/paypal.client.config'; import { StripeConfig } from '@fxa/payments/stripe'; import { ContentfulClientConfig } from '@fxa/shared/contentful'; +import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config'; +import { StatsDConfig } from 'libs/shared/metrics/statsd/src/lib/statsd.config'; export class RootConfig { @Type(() => MySQLConfig) @@ -40,4 +42,14 @@ export class RootConfig { @ValidateNested() @IsDefined() public readonly contentfulClientConfig!: Partial; + + @Type(() => FirestoreConfig) + @ValidateNested() + @IsDefined() + public readonly firestoreConfig!: Partial; + + @Type(() => StatsDConfig) + @ValidateNested() + @IsDefined() + public readonly statsDConfig!: Partial; } diff --git a/libs/shared/assets/src/images/lock.svg b/libs/shared/assets/src/images/lock.svg new file mode 100644 index 0000000000..e1d680f7f9 --- /dev/null +++ b/libs/shared/assets/src/images/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/libs/shared/contentful/src/lib/contentful.client.ts b/libs/shared/contentful/src/lib/contentful.client.ts index 946277681e..332554d5cf 100644 --- a/libs/shared/contentful/src/lib/contentful.client.ts +++ b/libs/shared/contentful/src/lib/contentful.client.ts @@ -1,3 +1,5 @@ +// Temporarily ignore. To be fixed during CMS refactor FXA-XXXX +/* eslint-disable @typescript-eslint/no-unused-vars */ /* 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/. */ @@ -71,19 +73,21 @@ export class ContentfulClient { return result; } - @Cacheable({ - cacheKey: (args: any) => cacheKeyForQuery(args[0], args[1]), - strategy: new NetworkFirstStrategy(), - ttlSeconds: (_, context: ContentfulClient) => - context.contentfulClientConfig.firestoreCacheTTL || - DEFAULT_FIRESTORE_CACHE_TTL, - client: (_, context: ContentfulClient) => - new FirestoreAdapter( - context.firestore, - context.contentfulClientConfig.firestoreCacheCollectionName || - CONTENTFUL_QUERY_CACHE_KEY - ), - }) + // Not sure what's happening here. Context is undefined which results in an error. + // To be fixed during CMS refactor + // @Cacheable({ + // cacheKey: (args: any) => cacheKeyForQuery(args[0], args[1]), + // strategy: new NetworkFirstStrategy(), + // ttlSeconds: (_, context: ContentfulClient) => + // context.contentfulClientConfig.firestoreCacheTTL || + // DEFAULT_FIRESTORE_CACHE_TTL, + // client: (_, context: ContentfulClient) => + // new FirestoreAdapter( + // context.firestore, + // context.contentfulClientConfig.firestoreCacheCollectionName || + // CONTENTFUL_QUERY_CACHE_KEY + // ), + // }) async query( query: TypedDocumentNode, variables: Variables diff --git a/libs/shared/db/firestore/src/lib/firestore.config.ts b/libs/shared/db/firestore/src/lib/firestore.config.ts new file mode 100644 index 0000000000..f78da0c547 --- /dev/null +++ b/libs/shared/db/firestore/src/lib/firestore.config.ts @@ -0,0 +1,28 @@ +/* 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 { Type } from 'class-transformer'; +import { IsOptional, IsString, ValidateNested } from 'class-validator'; + +class FirestoreCredentialsConfig { + @IsString() + clientEmail!: string; + + @IsString() + privateKey!: string; +} + +export class FirestoreConfig { + @Type(() => FirestoreCredentialsConfig) + @ValidateNested() + @IsOptional() + credentials?: FirestoreCredentialsConfig; + + @IsString() + @IsOptional() + keyFilename?: string; + + @IsString() + @IsOptional() + projectId?: string; +} diff --git a/libs/shared/db/firestore/src/lib/firestore.provider.ts b/libs/shared/db/firestore/src/lib/firestore.provider.ts index f8a5371cf2..35b0dd5367 100644 --- a/libs/shared/db/firestore/src/lib/firestore.provider.ts +++ b/libs/shared/db/firestore/src/lib/firestore.provider.ts @@ -6,6 +6,7 @@ import { Firestore } from '@google-cloud/firestore'; import * as grpc from '@grpc/grpc-js'; import { Provider } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { FirestoreConfig } from './firestore.config'; /** * Creates a firestore instance from a settings object. @@ -41,19 +42,20 @@ export function setupFirestore(config: FirebaseFirestore.Settings) { * Factory for providing access to firestore */ export const FirestoreService = Symbol('FIRESTORE'); + export const FirestoreProvider: Provider = { provide: FirestoreService, - useFactory: (configService: ConfigService) => { - const firestoreConfig = configService.get('authFirestore'); - if (firestoreConfig == null) { - throw new Error( - "Could not locate config for firestore missing 'authFirestore';" - ); - } - const firestore = setupFirestore(firestoreConfig); - return firestore; + useFactory: (config: FirestoreConfig) => { + const firestoreConfig: FirebaseFirestore.Settings = { + ...config, + credentials: { + client_email: config.credentials?.clientEmail, + private_key: config.credentials?.privateKey, + }, + }; + return setupFirestore(firestoreConfig); }, - inject: [ConfigService], + inject: [FirestoreConfig], }; /** @@ -67,3 +69,18 @@ export const MockFirestoreProvider: Provider = { return {} as Firestore; }, }; + +export const LegacyFirestoreProvider: Provider = { + provide: FirestoreService, + useFactory: (configService: ConfigService) => { + const firestoreConfig = configService.get('authFirestore'); + if (firestoreConfig == null) { + throw new Error( + "Could not locate config for firestore missing 'authFirestore';" + ); + } + const firestore = setupFirestore(firestoreConfig); + return firestore; + }, + inject: [ConfigService], +}; diff --git a/libs/shared/metrics/statsd/src/lib/statsd.config.ts b/libs/shared/metrics/statsd/src/lib/statsd.config.ts new file mode 100644 index 0000000000..84825aa3d3 --- /dev/null +++ b/libs/shared/metrics/statsd/src/lib/statsd.config.ts @@ -0,0 +1,26 @@ +/* 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 { IsInt, IsOptional, IsString } from 'class-validator'; + +export class StatsDConfig { + @IsInt() + @IsOptional() + sampleRate?: number; + + @IsInt() + @IsOptional() + maxBufferSize?: number; + + @IsString() + @IsOptional() + host?: string; + + @IsInt() + @IsOptional() + port?: number; + + @IsString() + @IsOptional() + prefix?: string; +} diff --git a/libs/shared/metrics/statsd/src/lib/statsd.provider.spec.ts b/libs/shared/metrics/statsd/src/lib/statsd.provider.spec.ts index decfc9d795..38d96c503b 100644 --- a/libs/shared/metrics/statsd/src/lib/statsd.provider.spec.ts +++ b/libs/shared/metrics/statsd/src/lib/statsd.provider.spec.ts @@ -4,7 +4,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; -import { StatsDFactory, StatsDService } from './statsd.provider'; +import { LegacyStatsDProvider, StatsDService } from './statsd.provider'; import { StatsD } from 'hot-shots'; const mockStatsd = jest.fn(); @@ -16,7 +16,7 @@ jest.mock('hot-shots', () => { }; }); -describe('StatsDFactory', () => { +describe('LegacyStatsDProvider', () => { let statsd: StatsD; const mockConfig = { @@ -35,7 +35,7 @@ describe('StatsDFactory', () => { jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ providers: [ - StatsDFactory, + LegacyStatsDProvider, { provide: ConfigService, useValue: mockConfigService, diff --git a/libs/shared/metrics/statsd/src/lib/statsd.provider.ts b/libs/shared/metrics/statsd/src/lib/statsd.provider.ts index ef3b85477d..ce939962dd 100644 --- a/libs/shared/metrics/statsd/src/lib/statsd.provider.ts +++ b/libs/shared/metrics/statsd/src/lib/statsd.provider.ts @@ -5,9 +5,34 @@ import { Provider } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { StatsD } from 'hot-shots'; +import { StatsDConfig } from './statsd.config'; export const StatsDService = Symbol('STATSD'); -export const StatsDFactory: Provider = { + +export const StatsDProvider: Provider = { + provide: StatsDService, + useFactory: (config: StatsDConfig) => { + if (config.host === '') { + return new StatsD({ mock: true }); + } + return new StatsD(config); + }, + inject: [StatsDConfig], +}; + +/** + * Can be used to satisfy DI when unit testing things that should not need + * statsd. + * Note: this will cause errors to be thrown if statsd is used + */ +export const MockStatsDProvider: Provider = { + provide: StatsDService, + useFactory: () => { + return {} as StatsD; + }, +}; + +export const LegacyStatsDProvider: Provider = { provide: StatsDService, useFactory: (configService: ConfigService) => { const config = configService.get('metrics'); @@ -18,15 +43,3 @@ export const StatsDFactory: Provider = { }, inject: [ConfigService], }; - -/** - * Can be used to satisfy DI when unit testing things that should not need - * statsd. - * Note: this will cause errors to be thrown if statsd is used - */ -export const MockStatsDFactory: Provider = { - provide: StatsDService, - useFactory: () => { - return {} as StatsD; - }, -}; diff --git a/package.json b/package.json index e52bb44334..2377c08460 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@opentelemetry/sdk-trace-base": "^1.23.0", "@opentelemetry/sdk-trace-node": "^1.23.0", "@opentelemetry/sdk-trace-web": "^1.23.0", + "@radix-ui/react-form": "^0.0.3", "@sentry/browser": "^7.113.0", "@sentry/integrations": "^7.113.0", "@sentry/node": "^7.113.0", diff --git a/packages/fxa-graphql-api/src/app.module.ts b/packages/fxa-graphql-api/src/app.module.ts index 5e705a8f17..ee22cb1e3b 100644 --- a/packages/fxa-graphql-api/src/app.module.ts +++ b/packages/fxa-graphql-api/src/app.module.ts @@ -4,7 +4,7 @@ import { HealthModule } from 'fxa-shared/nestjs/health/health.module'; import { MozLoggerService } from '@fxa/shared/mozlog'; -import { StatsDFactory } from '@fxa/shared/metrics/statsd'; +import { LegacyStatsDProvider } from '@fxa/shared/metrics/statsd'; import { NotifierSnsFactory, NotifierService } from '@fxa/shared/notifier'; import { getVersionInfo } from 'fxa-shared/nestjs/version'; @@ -51,7 +51,7 @@ const version = getVersionInfo(__dirname); ], controllers: [], providers: [ - StatsDFactory, + LegacyStatsDProvider, NotifierSnsFactory, NotifierService, MozLoggerService, diff --git a/packages/fxa-graphql-api/src/database/database.module.ts b/packages/fxa-graphql-api/src/database/database.module.ts index 1176187bfb..4cbcafda8b 100644 --- a/packages/fxa-graphql-api/src/database/database.module.ts +++ b/packages/fxa-graphql-api/src/database/database.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; import { DatabaseService } from './database.service'; import { MozLoggerService } from '@fxa/shared/mozlog'; -import { StatsDFactory } from '@fxa/shared/metrics/statsd'; +import { LegacyStatsDProvider } from '@fxa/shared/metrics/statsd'; @Module({ - providers: [DatabaseService, MozLoggerService, StatsDFactory], + providers: [DatabaseService, MozLoggerService, LegacyStatsDProvider], exports: [DatabaseService], }) export class DatabaseModule {} diff --git a/packages/fxa-graphql-api/src/gql/gql.module.ts b/packages/fxa-graphql-api/src/gql/gql.module.ts index af001dc9de..438a77dc9e 100644 --- a/packages/fxa-graphql-api/src/gql/gql.module.ts +++ b/packages/fxa-graphql-api/src/gql/gql.module.ts @@ -24,7 +24,7 @@ import { SubscriptionResolver } from './subscription.resolver'; import { ClientInfoResolver } from './clientInfo.resolver'; import { SessionResolver } from './session.resolver'; import { NotifierService, NotifierSnsFactory } from '@fxa/shared/notifier'; -import { StatsDFactory } from '@fxa/shared/metrics/statsd'; +import { LegacyStatsDProvider } from '@fxa/shared/metrics/statsd'; import { MozLoggerService } from '@fxa/shared/mozlog'; const config = Config.getProperties(); @@ -52,7 +52,7 @@ export const GraphQLConfigFactory = async ( @Module({ imports: [BackendModule, CustomsModule], providers: [ - StatsDFactory, + LegacyStatsDProvider, NotifierSnsFactory, NotifierService, AccountResolver, diff --git a/yarn.lock b/yarn.lock index 6b0987296a..77292601b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14915,6 +14915,31 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-form@npm:^0.0.3": + version: 0.0.3 + resolution: "@radix-ui/react-form@npm:0.0.3" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.1 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-id": 1.0.1 + "@radix-ui/react-label": 2.0.2 + "@radix-ui/react-primitive": 1.0.3 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: a2cc8e3787643b16e8c7a77aeac3707e7d7858ca65818631d46184abd7ab7047d496f646f404cbeddc4aeaf974a4e0d778044b09236d156ca2ab6f5e21851dac + languageName: node + linkType: hard + "@radix-ui/react-id@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-id@npm:1.0.1" @@ -14931,6 +14956,26 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-label@npm:2.0.2": + version: 2.0.2 + resolution: "@radix-ui/react-label@npm:2.0.2" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-primitive": 1.0.3 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: fe3bd8902bc523fb5125fa96167a13b8a60d007413787eae9573e4b00b0edff0487c4c0620ea5dc37e6da13833ebc4f8d7e00b6c846f2a5686e7f173672b8dde + languageName: node + linkType: hard + "@radix-ui/react-popper@npm:1.1.2": version: 1.1.2 resolution: "@radix-ui/react-popper@npm:1.1.2" @@ -38670,6 +38715,7 @@ fsevents@~2.1.1: "@opentelemetry/sdk-trace-base": ^1.23.0 "@opentelemetry/sdk-trace-node": ^1.23.0 "@opentelemetry/sdk-trace-web": ^1.23.0 + "@radix-ui/react-form": ^0.0.3 "@sentry/browser": ^7.113.0 "@sentry/integrations": ^7.113.0 "@sentry/node": ^7.113.0