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: