diff --git a/src/actions/__tests__/payment-profile-actions.test.js b/src/actions/__tests__/payment-profile-actions.test.js index b3c88bfb4..6484dfab9 100644 --- a/src/actions/__tests__/payment-profile-actions.test.js +++ b/src/actions/__tests__/payment-profile-actions.test.js @@ -288,6 +288,25 @@ describe("savePaymentProfile", () => { expect(putRequest.mock.calls[0][3]).toEqual(EXISTING_PROFILE); }); + test("sends secret keys only when typed and never puts them in the request action", async () => { + store.dispatch( + savePaymentProfile({ + ...EXISTING_PROFILE, + has_live_secret_key: true, + has_test_secret_key: false, + live_secret_key: "", + test_secret_key: "sk_test_new" + }) + ); + await flushPromises(); + + expect(putRequest.mock.calls[0][3]).toEqual({ + ...EXISTING_PROFILE, + test_secret_key: "sk_test_new" + }); + expect(JSON.stringify(store.getActions())).not.toContain("sk_test_new"); + }); + test("STOP_LOADING is dispatched even when the request fails", async () => { putRequest.mockImplementation( () => () => () => Promise.reject(new Error("network error")) diff --git a/src/actions/ticket-actions.js b/src/actions/ticket-actions.js index 6422c77c4..ef8f54056 100644 --- a/src/actions/ticket-actions.js +++ b/src/actions/ticket-actions.js @@ -1258,6 +1258,23 @@ export const getPaymentProfiles = }); }; +// secret keys are write-only: send them only when the user typed a new one, +// an omitted key keeps the stored value +const normalizePaymentProfile = (entity) => { + const normalizedEntity = { ...entity }; + + if (!normalizedEntity.live_secret_key) + delete normalizedEntity.live_secret_key; + + if (!normalizedEntity.test_secret_key) + delete normalizedEntity.test_secret_key; + + delete normalizedEntity.has_live_secret_key; + delete normalizedEntity.has_test_secret_key; + + return normalizedEntity; +}; + export const savePaymentProfile = (entity) => async (dispatch, getState) => { const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); @@ -1269,14 +1286,15 @@ export const savePaymentProfile = (entity) => async (dispatch, getState) => { access_token: accessToken }; + const normalizedEntity = normalizePaymentProfile(entity); + if (entity.id) { return putRequest( createAction(UPDATE_PAYMENT_PROFILE), createAction(PAYMENT_PROFILE_UPDATED), `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/payment-profiles/${entity.id}`, - entity, - snackbarErrorHandler, - entity + normalizedEntity, + snackbarErrorHandler )(params)(dispatch) .then(() => { dispatch( @@ -1293,7 +1311,7 @@ export const savePaymentProfile = (entity) => async (dispatch, getState) => { createAction(UPDATE_PAYMENT_PROFILE), createAction(PAYMENT_PROFILE_ADDED), `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/payment-profiles`, - entity, + normalizedEntity, snackbarErrorHandler )(params)(dispatch) .then(() => { diff --git a/src/i18n/en.json b/src/i18n/en.json index b61ff3547..bf3b20112 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2057,6 +2057,9 @@ "live_publishable_key": "Live Publishable Key", "test_secret_key": "Test Secret Key", "test_publishable_key": "Test Publishable Key", + "secret_key_set": "Secret key: set", + "secret_key_not_set": "Secret key: not set", + "replace": "Replace", "info_stripe_keys": "Get your API keys from your stripe account.", "info_stripe_test_mode": "Place the payment gateway in test mode using test API keys.", "info_send_email_receipt": "Email Customer about successful payments.", diff --git a/src/pages/tickets/payment-profile/components/__tests__/payment-profile-dialog.test.js b/src/pages/tickets/payment-profile/components/__tests__/payment-profile-dialog.test.js index f07288bce..3522a7d5c 100644 --- a/src/pages/tickets/payment-profile/components/__tests__/payment-profile-dialog.test.js +++ b/src/pages/tickets/payment-profile/components/__tests__/payment-profile-dialog.test.js @@ -169,9 +169,9 @@ const feeTypeSectionEntity = { test_mode_enabled: false, send_email_receipt: false, merchant_account_id: "", - live_secret_key: "sk_live_xxx", + has_live_secret_key: true, live_publishable_key: "pk_live_xxx", - test_secret_key: "", + has_test_secret_key: false, test_publishable_key: "" }; @@ -267,6 +267,43 @@ describe("PaymentProfileDialog", () => { }); }); + test("a stored secret key shows as set, is only replaced through an empty password field and cancelling discards the typed value", async () => { + renderDialog({ entity: feeTypeSectionEntity }); + + expect( + screen.getByText("edit_payment_profile.secret_key_set") + ).toBeInTheDocument(); + expect( + screen.queryByTestId("input-live_secret_key") + ).not.toBeInTheDocument(); + // test key is not set, so its input is shown right away + expect(screen.getByTestId("input-test_secret_key")).toHaveAttribute( + "type", + "password" + ); + + fireEvent.click( + screen.getByRole("button", { name: "edit_payment_profile.replace" }) + ); + const liveInput = screen.getByTestId("input-live_secret_key"); + expect(liveInput).toHaveAttribute("type", "password"); + expect(liveInput).toHaveValue(""); + + fireEvent.change(liveInput, { target: { value: "sk_live_new" } }); + fireEvent.click(screen.getByRole("button", { name: "general.cancel" })); + expect( + screen.queryByTestId("input-live_secret_key") + ).not.toBeInTheDocument(); + + submitProfileForm(); + + await waitFor(() => { + expect(defaultProps.onSave).toHaveBeenCalledWith( + expect.objectContaining({ live_secret_key: "" }) + ); + }); + }); + test("provider selection shows the correct provider-specific fields", () => { renderDialog({ entity: {} }); diff --git a/src/pages/tickets/payment-profile/components/info-tooltip.js b/src/pages/tickets/payment-profile/components/info-tooltip.js new file mode 100644 index 000000000..17556429f --- /dev/null +++ b/src/pages/tickets/payment-profile/components/info-tooltip.js @@ -0,0 +1,20 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Tooltip } from "@mui/material"; +import InfoIcon from "@mui/icons-material/Info"; + +const InfoTooltip = ({ title }) => ( + + + +); + +InfoTooltip.propTypes = { + title: PropTypes.string.isRequired +}; + +export default InfoTooltip; diff --git a/src/pages/tickets/payment-profile/components/payment-profile-dialog.js b/src/pages/tickets/payment-profile/components/payment-profile-dialog.js index 2bec371ea..a49bf2593 100644 --- a/src/pages/tickets/payment-profile/components/payment-profile-dialog.js +++ b/src/pages/tickets/payment-profile/components/payment-profile-dialog.js @@ -14,12 +14,10 @@ import { MenuItem, Box, InputLabel, - TextField, - Tooltip + TextField } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import AddIcon from "@mui/icons-material/Add"; -import InfoIcon from "@mui/icons-material/Info"; import { useFormik, FormikProvider } from "formik"; import * as yup from "yup"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; @@ -31,20 +29,8 @@ import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/m import useScrollToError from "../../../../hooks/useScrollToError"; import { nullableDecimalValidation } from "../../../../utils/yup"; import { ONE_HUNDRED, TEN_THOUSAND } from "../../../../utils/constants"; - -const InfoTooltip = ({ title }) => ( - - - -); - -InfoTooltip.propTypes = { - title: PropTypes.string.isRequired -}; +import InfoTooltip from "./info-tooltip"; +import SecretKeyField from "./secret-key-field"; const APPLICATION_TYPE_OPTIONS = [ { label: "Registration", value: "Registration" }, @@ -115,9 +101,9 @@ const PaymentProfileDialog = ({ test_mode_enabled: initialEntity?.test_mode_enabled || false, send_email_receipt: initialEntity?.send_email_receipt || false, merchant_account_id: initialEntity?.merchant_account_id || "", - live_secret_key: initialEntity?.live_secret_key || "", + live_secret_key: "", live_publishable_key: initialEntity?.live_publishable_key || "", - test_secret_key: initialEntity?.test_secret_key || "", + test_secret_key: "", test_publishable_key: initialEntity?.test_publishable_key || "" }, validationSchema: yup.object().shape({ @@ -283,7 +269,7 @@ const PaymentProfileDialog = ({ - + {T.translate("edit_payment_profile.application_type")} @@ -301,7 +287,7 @@ const PaymentProfileDialog = ({ ))} - + {T.translate("edit_payment_profile.provider")} @@ -314,13 +300,13 @@ const PaymentProfileDialog = ({ - + - + {formik.values.provider === "Stripe" && ( - + )} - - - {T.translate("edit_payment_profile.live_secret_key")} - - - + - + {T.translate("edit_payment_profile.live_publishable_key")} - - - {T.translate("edit_payment_profile.test_secret_key")} - - - + - + {T.translate("edit_payment_profile.test_publishable_key")} {showFeeTypeForm && ( - + {T.translate( "edit_payment_profile.payment_type_fee_name" @@ -472,7 +446,7 @@ const PaymentProfileDialog = ({ margin="none" /> - + {T.translate( "edit_payment_profile.payment_type_fee_kind" @@ -506,7 +480,7 @@ const PaymentProfileDialog = ({ ))} - + {T.translate( "edit_payment_profile.payment_type_fee_method" @@ -539,7 +513,7 @@ const PaymentProfileDialog = ({ ))} - + {T.translate( "edit_payment_profile.payment_type_fee_value" @@ -597,7 +571,7 @@ const PaymentProfileDialog = ({ /> )} - + {T.translate( "edit_payment_profile.payment_type_fee_max_cents" @@ -611,7 +585,7 @@ const PaymentProfileDialog = ({ inCents /> - + {T.translate( "edit_payment_profile.payment_type_fee_min_cents" @@ -700,9 +674,9 @@ PaymentProfileDialog.propTypes = { test_mode_enabled: PropTypes.bool, send_email_receipt: PropTypes.bool, merchant_account_id: PropTypes.string, - live_secret_key: PropTypes.string, + has_live_secret_key: PropTypes.bool, live_publishable_key: PropTypes.string, - test_secret_key: PropTypes.string, + has_test_secret_key: PropTypes.bool, test_publishable_key: PropTypes.string }), paymentFeeTypes: PropTypes.shape({ diff --git a/src/pages/tickets/payment-profile/components/secret-key-field.js b/src/pages/tickets/payment-profile/components/secret-key-field.js new file mode 100644 index 000000000..3c51ef426 --- /dev/null +++ b/src/pages/tickets/payment-profile/components/secret-key-field.js @@ -0,0 +1,74 @@ +import React, { useState } from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import { Box, Button, InputLabel, Typography } from "@mui/material"; +import { useFormikContext } from "formik"; +import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; +import InfoTooltip from "./info-tooltip"; + +const SecretKeyField = ({ name, label, isSet, required }) => { + const { setFieldValue } = useFormikContext(); + const [isReplacing, setIsReplacing] = useState(false); + + const handleToggleReplace = () => { + if (isReplacing) setFieldValue(name, ""); + setIsReplacing(!isReplacing); + }; + + return ( + <> + + {label} + + + {(!isSet || isReplacing) && ( + + )} + + + {T.translate( + isSet + ? "edit_payment_profile.secret_key_set" + : "edit_payment_profile.secret_key_not_set" + )} + + {isSet && ( + + )} + + + ); +}; + +SecretKeyField.propTypes = { + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isSet: PropTypes.bool, + required: PropTypes.bool +}; + +SecretKeyField.defaultProps = { + isSet: false, + required: false +}; + +export default SecretKeyField; diff --git a/src/reducers/payment_profiles/__tests__/payment-profile-reducer.test.js b/src/reducers/payment_profiles/__tests__/payment-profile-reducer.test.js new file mode 100644 index 000000000..bf5834b09 --- /dev/null +++ b/src/reducers/payment_profiles/__tests__/payment-profile-reducer.test.js @@ -0,0 +1,34 @@ +import paymentProfileReducer, { + DEFAULT_ENTITY +} from "../payment-profile-reducer"; +import { + RECEIVE_PAYMENT_PROFILE, + PAYMENT_PROFILE_UPDATED +} from "../../../actions/ticket-actions"; + +describe("paymentProfileReducer", () => { + test.each([RECEIVE_PAYMENT_PROFILE, PAYMENT_PROFILE_UPDATED])( + "%s never stores secret keys", + (type) => { + const state = paymentProfileReducer(undefined, { + type, + payload: { + response: { + id: 5, + live_secret_key: "sk_live_xxx", + test_secret_key: "sk_test_xxx", + has_live_secret_key: true, + merchant_account_id: null + } + } + }); + + expect(state.entity).toEqual({ + ...DEFAULT_ENTITY, + id: 5, + has_live_secret_key: true, + merchant_account_id: "" + }); + } + ); +}); diff --git a/src/reducers/payment_profiles/payment-profile-reducer.js b/src/reducers/payment_profiles/payment-profile-reducer.js index d33f926fe..6a5c515f0 100644 --- a/src/reducers/payment_profiles/payment-profile-reducer.js +++ b/src/reducers/payment_profiles/payment-profile-reducer.js @@ -28,9 +28,9 @@ export const DEFAULT_ENTITY = { application_type: null, provider: "Stripe", test_mode_enabled: false, - live_secret_key: "", + has_live_secret_key: false, live_publishable_key: "", - test_secret_key: "", + has_test_secret_key: false, test_publishable_key: "", send_email_receipt: false, merchant_account_id: "" @@ -54,9 +54,13 @@ const paymentProfileReducer = (state = DEFAULT_STATE, action) => { case RESET_PAYMENT_PROFILE_FORM: return { ...state, entity: { ...DEFAULT_ENTITY }, errors: {} }; case PAYMENT_PROFILE_ADDED: - case RECEIVE_PAYMENT_PROFILE: { + case RECEIVE_PAYMENT_PROFILE: + case PAYMENT_PROFILE_UPDATED: { const entity = { ...payload.response }; + delete entity.live_secret_key; + delete entity.test_secret_key; + for (const key in entity) { if (entity.hasOwnProperty(key)) { entity[key] = entity[key] == null ? "" : entity[key]; @@ -65,10 +69,6 @@ const paymentProfileReducer = (state = DEFAULT_STATE, action) => { return { ...state, entity: { ...DEFAULT_ENTITY, ...entity } }; } - case PAYMENT_PROFILE_UPDATED: { - const entity = payload.response; - return { ...state, entity }; - } case VALIDATE: return { ...state, errors: payload.errors }; default: