From 1bd1e563f993ec483ef43651996b9c836e33de0d Mon Sep 17 00:00:00 2001 From: s-vamshi <62489114+s-vamshi@users.noreply.github.com> Date: Wed, 23 Oct 2024 16:16:51 +0530 Subject: [PATCH 1/8] test: creation of test payment using sdk cypress (#1555) --- config/config.toml | 2 +- cypress.config.js | 1 + cypress/e2e/connector/connector.cy.js | 65 ++++++++++++++++++++++----- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/config/config.toml b/config/config.toml index 80696aa4f..2c1098c21 100644 --- a/config/config.toml +++ b/config/config.toml @@ -4,7 +4,7 @@ primary_hover_color="#005ED6" sidebar_color="#242F48" [default.endpoints] api_url="http://localhost:8080" -sdk_url="" +sdk_url="http://localhost:9050/HyperLoader.js" logo_url="" favicon_url="" agreement_url="" diff --git a/cypress.config.js b/cypress.config.js index 13df40532..39d284c70 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -9,6 +9,7 @@ module.exports = defineConfig({ // with any changed environment variables return config; }, + chromeWebSecurity: false, }, env: { CYPRESS_USERNAME: process.env.CYPRESS_USERNAME || "cypress@gmail.com", diff --git a/cypress/e2e/connector/connector.cy.js b/cypress/e2e/connector/connector.cy.js index 3241f2d5e..24f548598 100644 --- a/cypress/e2e/connector/connector.cy.js +++ b/cypress/e2e/connector/connector.cy.js @@ -1,9 +1,16 @@ describe("connector", () => { - const username = `cypress${Math.round(+new Date() / 1000)}@gmail.com`; const password = "Cypress98#"; + const username = `cypress${Math.round(+new Date() / 1000)}@gmail.com`; + + const getIframeBody = () => { + return cy + .get("iframe") + .its("0.contentDocument.body") + .should("not.be.empty") + .then(cy.wrap); + }; - // Login before each testcase - beforeEach(() => { + before(() => { cy.visit("http://localhost:9000/dashboard/login"); cy.url().should("include", "/login"); cy.get("[data-testid=card-header]").should( @@ -23,16 +30,27 @@ describe("connector", () => { cy.get('button[type="submit"]').click({ force: true }); cy.get("[data-testid=skip-now]").click({ force: true }); - cy.url().should("include", "/dashboard/home"); - }); - - it("Create a dummy connector", () => { - cy.url().should("include", "/dashboard/home"); - cy.get('[data-form-label="Business name"]').should("exist"); cy.get("[data-testid=merchant_name]").type("test_business"); cy.get("[data-button-for=startExploring]").click(); - cy.reload(true); + }); + + beforeEach(function () { + if (this.currentTest.title !== "Create a dummy connector") { + cy.visit("http://localhost:9000/dashboard/login"); + cy.url().should("include", "/login"); + cy.get("[data-testid=card-header]").should( + "contain", + "Hey there, Welcome back!", + ); + cy.get("[data-testid=email]").type(username); + cy.get("[data-testid=password]").type(password); + cy.get('button[type="submit"]').click({ force: true }); + cy.get("[data-testid=skip-now]").click({ force: true }); + } + }); + + it("Create a dummy connector", () => { cy.get("[data-testid=connectors]").click(); cy.get("[data-testid=paymentprocessors]").click(); cy.contains("Payment Processors").should("be.visible"); @@ -69,4 +87,31 @@ describe("connector", () => { .scrollIntoView() .should("be.visible"); }); + it("Use the SDK to process a payment", () => { + cy.get("[data-testid=connectors]").click(); + cy.get("[data-testid=paymentprocessors]").click(); + cy.contains("Payment Processors").should("be.visible"); + cy.get("[data-testid=home]").click(); + cy.get("[data-button-for=tryItOut]").click(); + cy.get('[data-breadcrumb="Explore Demo Checkout Experience"]').should( + "exist", + ); + cy.get('[data-value="unitedStates(USD)"]').click(); + cy.get('[data-dropdown-value="Germany (EUR)"]').click(); + cy.get("[data-testid=amount]").find("input").clear().type("77"); + cy.get("[data-button-for=showPreview]").click(); + getIframeBody() + .find("[data-testid=cardNoInput]", { timeout: 20000 }) + .should("exist") + .type("4242424242424242"); + getIframeBody() + .find("[data-testid=expiryInput]") + .should("exist") + .type("0127"); + getIframeBody().find("[data-testid=cvvInput]").should("exist").type("492"); + cy.get("[data-button-for=payEUR77]").should("exist").click(); + cy.contains("Payment Successful").should("be.visible"); + cy.get("[data-button-for=goToPayment]").should("exist").click(); + cy.url().should("include", "dashboard/payments"); + }); }); From 2c31177c89fa122a139f2506ae12d7c1a497fb0a Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Oct 2024 00:26:10 +0000 Subject: [PATCH 2/8] chore(version): 2024.10.24.0 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25183812d..a02d3249d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file. See [conven - - - +## 2024.10.24.0 + +### Testing + +- Creation of test payment using sdk cypress ([#1555](https://github.com/juspay/hyperswitch-control-center/pull/1555)) ([`1bd1e56`](https://github.com/juspay/hyperswitch-control-center/commit/1bd1e563f993ec483ef43651996b9c836e33de0d)) + +**Full Changelog:** [`2024.10.22.2...2024.10.24.0`](https://github.com/juspay/hyperswitch-control-center/compare/2024.10.22.2...2024.10.24.0) + +- - - + ## 2024.10.22.2 ### Bug Fixes From 9ff488b8edc99af45fc8f77ab1f56e6cef34a838 Mon Sep 17 00:00:00 2001 From: Riddhiagrawal001 <50551695+Riddhiagrawal001@users.noreply.github.com> Date: Thu, 24 Oct 2024 12:12:48 +0530 Subject: [PATCH 3/8] chore: TwoFa restriction after multiple failed attempts before login (#1594) --- .../PreLoginModule/DecisionScreen.res | 2 +- .../AuthModule/TwoFaAuth/TotpSetup.res | 34 +++-- .../AuthModule/TwoFaAuth/TwoFaLanding.res | 126 ++++++++++++++++++ .../AuthModule/TwoFaAuth/TwoFaTypes.res | 24 +++- .../AuthModule/TwoFaAuth/TwoFaUtils.res | 30 +++++ src/screens/APIUtils/APIUtils.res | 1 + src/screens/APIUtils/APIUtilsTypes.res | 1 + 7 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 src/entryPoints/AuthModule/TwoFaAuth/TwoFaLanding.res diff --git a/src/entryPoints/AuthModule/PreLoginModule/DecisionScreen.res b/src/entryPoints/AuthModule/PreLoginModule/DecisionScreen.res index d2c264667..64d6657c1 100644 --- a/src/entryPoints/AuthModule/PreLoginModule/DecisionScreen.res +++ b/src/entryPoints/AuthModule/PreLoginModule/DecisionScreen.res @@ -18,7 +18,7 @@ let make = () => { | MERCHANT_SELECT | ACCEPT_INVITE => - | TOTP => + | TOTP => | FORCE_SET_PASSWORD | RESET_PASSWORD => diff --git a/src/entryPoints/AuthModule/TwoFaAuth/TotpSetup.res b/src/entryPoints/AuthModule/TwoFaAuth/TotpSetup.res index daf68f163..a07064f4f 100644 --- a/src/entryPoints/AuthModule/TwoFaAuth/TotpSetup.res +++ b/src/entryPoints/AuthModule/TwoFaAuth/TotpSetup.res @@ -4,16 +4,15 @@ let p3Regular = HSwitchUtils.getTextClass((P3, Regular)) module EnterAccessCode = { @react.component - let make = (~setTwoFaPageState, ~onClickVerifyAccessCode) => { + let make = (~setTwoFaPageState, ~onClickVerifyAccessCode, ~errorHandling) => { let showToast = ToastState.useShowToast() let verifyRecoveryCodeLogic = TotpHooks.useVerifyRecoveryCode() let (recoveryCode, setRecoveryCode) = React.useState(_ => "") let (buttonState, setButtonState) = React.useState(_ => Button.Normal) - let verifyAccessCode = async () => { + let verifyAccessCode = async _ => { + open LogicUtils try { - open LogicUtils - setButtonState(_ => Button.Loading) if recoveryCode->String.length > 0 { @@ -25,7 +24,12 @@ module EnterAccessCode = { } setButtonState(_ => Button.Normal) } catch { - | _ => { + | Exn.Error(e) => { + let err = Exn.message(e)->Option.getOr("Something went wrong") + let errorCode = err->safeParse->getDictFromJsonObject->getString("code", "") + if errorCode == "UR_49" { + errorHandling(errorCode) + } setRecoveryCode(_ => "") setButtonState(_ => Button.Normal) } @@ -105,6 +109,7 @@ module ConfigureTotpScreen = { ~twoFaStatus, ~setTwoFaPageState, ~terminateTwoFactorAuth, + ~errorHandling, ) => { open TwoFaTypes @@ -115,9 +120,8 @@ module ConfigureTotpScreen = { let (buttonState, setButtonState) = React.useState(_ => Button.Normal) let verifyTOTP = async () => { + open LogicUtils try { - open LogicUtils - setButtonState(_ => Button.Loading) if otp->String.length > 0 { @@ -135,7 +139,12 @@ module ConfigureTotpScreen = { } setButtonState(_ => Button.Normal) } catch { - | _ => { + | Exn.Error(e) => { + let err = Exn.message(e)->Option.getOr("Something went wrong") + let errorCode = err->safeParse->getDictFromJsonObject->getString("code", "") + if errorCode == "UR_48" { + errorHandling(errorCode) + } setOtp(_ => "") setButtonState(_ => Button.Normal) } @@ -225,7 +234,7 @@ module ConfigureTotpScreen = { } @react.component -let make = () => { +let make = (~setTwoFaPageState, ~twoFaPageState, ~errorHandling) => { open HSwitchUtils open TwoFaTypes @@ -238,7 +247,6 @@ let make = () => { let (isQrVisible, setIsQrVisible) = React.useState(_ => false) let (totpUrl, setTotpUrl) = React.useState(_ => "") let (twoFaStatus, setTwoFaStatus) = React.useState(_ => TWO_FA_NOT_SET) - let (twoFaPageState, setTwoFaPageState) = React.useState(_ => TOTP_SHOW_QR) let (showNewQR, setShowNewQR) = React.useState(_ => false) let delayTimer = () => { @@ -325,14 +333,16 @@ let make = () => { {switch twoFaPageState { | TOTP_SHOW_QR => | TOTP_SHOW_RC => | TOTP_INPUT_RECOVERY_CODE => - + }}
{"Log in with a different account?"->React.string} diff --git a/src/entryPoints/AuthModule/TwoFaAuth/TwoFaLanding.res b/src/entryPoints/AuthModule/TwoFaAuth/TwoFaLanding.res new file mode 100644 index 000000000..0c384863e --- /dev/null +++ b/src/entryPoints/AuthModule/TwoFaAuth/TwoFaLanding.res @@ -0,0 +1,126 @@ +let p2Regular = HSwitchUtils.getTextClass((P2, Regular)) + +module AttemptsExpiredComponent = { + @react.component + let make = (~expiredType, ~setTwoFaPageState, ~setTwoFaStatus) => { + open TwoFaTypes + open HSwitchUtils + let {setAuthStatus} = React.useContext(AuthInfoProvider.authStatusContext) + + let belowComponent = switch expiredType { + | TOTP_ATTEMPTS_EXPIRED => +

+ {"or "->React.string} + { + setTwoFaStatus(_ => TwoFaNotExpired) + setTwoFaPageState(_ => TOTP_INPUT_RECOVERY_CODE) + }}> + {"Use recovery-code"->React.string} + +

+ | RC_ATTEMPTS_EXPIRED => +

+ {"or "->React.string} + { + setTwoFaStatus(_ => TwoFaNotExpired) + setTwoFaPageState(_ => TOTP_SHOW_QR) + }}> + {"Use totp"->React.string} + +

+ | TWO_FA_EXPIRED => React.null + } + + +
+
+

+ {"There have been multiple unsuccessful sign-in attempts for this account. Please wait a moment before trying again."->React.string} +

+ {belowComponent} +
+
+ {"Log in with a different account?"->React.string} +

setAuthStatus(LoggedOut)}> + {"Click here to log out."->React.string} +

+
+
+
+ } +} + +@react.component +let make = () => { + open TwoFaTypes + let getURL = APIUtils.useGetURL() + let fetchDetails = APIUtils.useGetMethod() + let {setAuthStatus} = React.useContext(AuthInfoProvider.authStatusContext) + let (twoFaStatus, setTwoFaStatus) = React.useState(_ => TwoFaNotExpired) + let (twoFaPageState, setTwoFaPageState) = React.useState(_ => TOTP_SHOW_QR) + let (screenState, setScreenState) = React.useState(_ => PageLoaderWrapper.Loading) + + let handlePageBasedOnAttempts = responseDict => { + switch responseDict { + | Some(value) => + if value.totp.attemptsRemaining > 0 && value.recoveryCode.attemptsRemaining > 0 { + setTwoFaStatus(_ => TwoFaNotExpired) + setTwoFaPageState(_ => TOTP_SHOW_QR) + } else if value.totp.attemptsRemaining == 0 && value.recoveryCode.attemptsRemaining == 0 { + setTwoFaStatus(_ => TwoFaExpired(TWO_FA_EXPIRED)) + } else if value.totp.attemptsRemaining == 0 { + setTwoFaStatus(_ => TwoFaExpired(TOTP_ATTEMPTS_EXPIRED)) + } else if value.recoveryCode.attemptsRemaining == 0 { + setTwoFaStatus(_ => TwoFaExpired(RC_ATTEMPTS_EXPIRED)) + } + | None => setTwoFaStatus(_ => TwoFaNotExpired) + } + } + + let checkTwofaStatus = async () => { + try { + let url = getURL( + ~entityName=USERS, + ~userType=#CHECK_TWO_FACTOR_AUTH_STATUS_V2, + ~methodType=Get, + ) + let response = await fetchDetails(url) + let responseDict = response->TwoFaUtils.jsonTocheckTwofaResponseType + handlePageBasedOnAttempts(responseDict.status) + setScreenState(_ => PageLoaderWrapper.Success) + } catch { + | _ => { + setScreenState(_ => PageLoaderWrapper.Error("Failed to fetch!")) + setAuthStatus(LoggedOut) + } + } + } + + let errorHandling = errorCode => { + if errorCode == "UR_48" { + setTwoFaStatus(_ => TwoFaExpired(TOTP_ATTEMPTS_EXPIRED)) + } else if errorCode == "UR_49" { + setTwoFaStatus(_ => TwoFaExpired(RC_ATTEMPTS_EXPIRED)) + } + } + + React.useEffect(() => { + checkTwofaStatus()->ignore + None + }, []) + + + {switch twoFaStatus { + | TwoFaExpired(expiredType) => + + | TwoFaNotExpired => + }} + +} diff --git a/src/entryPoints/AuthModule/TwoFaAuth/TwoFaTypes.res b/src/entryPoints/AuthModule/TwoFaAuth/TwoFaTypes.res index 289485444..fad0c7f59 100644 --- a/src/entryPoints/AuthModule/TwoFaAuth/TwoFaTypes.res +++ b/src/entryPoints/AuthModule/TwoFaAuth/TwoFaTypes.res @@ -1,3 +1,25 @@ -type twoFaPageState = TOTP_SHOW_QR | TOTP_SHOW_RC | TOTP_INPUT_RECOVERY_CODE +type twoFaPageState = + | TOTP_SHOW_QR + | TOTP_SHOW_RC + | TOTP_INPUT_RECOVERY_CODE type twoFaStatus = TWO_FA_NOT_SET | TWO_FA_SET + +type twoFaValueType = { + isCompleted: bool, + attemptsRemaining: int, +} + +type twoFatype = { + totp: twoFaValueType, + recoveryCode: twoFaValueType, +} + +type checkTwofaResponseType = {status: option} + +type expiredTypes = + | TOTP_ATTEMPTS_EXPIRED + | RC_ATTEMPTS_EXPIRED + | TWO_FA_EXPIRED + +type twoFaStatusType = TwoFaExpired(expiredTypes) | TwoFaNotExpired diff --git a/src/entryPoints/AuthModule/TwoFaAuth/TwoFaUtils.res b/src/entryPoints/AuthModule/TwoFaAuth/TwoFaUtils.res index 3832ceac7..8c5e06e8b 100644 --- a/src/entryPoints/AuthModule/TwoFaAuth/TwoFaUtils.res +++ b/src/entryPoints/AuthModule/TwoFaAuth/TwoFaUtils.res @@ -63,3 +63,33 @@ let downloadRecoveryCodes = (~recoveryCodes) => { ~content=JSON.stringifyWithIndent(recoveryCodes->getJsonFromArrayOfString, 3), ) } + +let jsonToTwoFaValueType: Dict.t<'a> => TwoFaTypes.twoFaValueType = dict => { + open LogicUtils + + { + isCompleted: dict->getBool("is_completed", false), + attemptsRemaining: dict->getInt("remaining_attempts", 4), + } +} + +let jsonTocheckTwofaResponseType: JSON.t => TwoFaTypes.checkTwofaResponseType = json => { + open LogicUtils + let jsonToDict = json->getDictFromJsonObject->Dict.get("status") + + let statusValue = switch jsonToDict { + | Some(json) => { + let dict = json->getDictFromJsonObject + let twoFaValue: TwoFaTypes.twoFatype = { + totp: dict->getDictfromDict("totp")->jsonToTwoFaValueType, + recoveryCode: dict->getDictfromDict("recovery_code")->jsonToTwoFaValueType, + } + Some(twoFaValue) + } + | None => None + } + + { + status: statusValue, + } +} diff --git a/src/screens/APIUtils/APIUtils.res b/src/screens/APIUtils/APIUtils.res index 6888eb4de..12f79baeb 100644 --- a/src/screens/APIUtils/APIUtils.res +++ b/src/screens/APIUtils/APIUtils.res @@ -661,6 +661,7 @@ let useGetURL = () => { // SPT FLOWS (Totp) | #BEGIN_TOTP => `${userUrl}/2fa/totp/begin` + | #CHECK_TWO_FACTOR_AUTH_STATUS_V2 => `${userUrl}/2fa/v2` | #VERIFY_TOTP => `${userUrl}/2fa/totp/verify` | #VERIFY_RECOVERY_CODE => `${userUrl}/2fa/recovery_code/verify` | #GENERATE_RECOVERY_CODES => `${userUrl}/2fa/recovery_code/generate` diff --git a/src/screens/APIUtils/APIUtilsTypes.res b/src/screens/APIUtils/APIUtilsTypes.res index aaa908515..82a6d2ca4 100644 --- a/src/screens/APIUtils/APIUtilsTypes.res +++ b/src/screens/APIUtils/APIUtilsTypes.res @@ -114,6 +114,7 @@ type userType = [ | #USER_DETAILS | #LIST_ROLES_FOR_ROLE_UPDATE | #ACCEPT_INVITATION_HOME + | #CHECK_TWO_FACTOR_AUTH_STATUS_V2 | #NONE ] From 46f4de64f0c68e587a62c876b56e94a5e25ec26d Mon Sep 17 00:00:00 2001 From: Gitanjli <96485413+gitanjli525@users.noreply.github.com> Date: Thu, 24 Oct 2024 12:16:51 +0530 Subject: [PATCH 4/8] fix: auto retry bug (#1639) --- src/screens/Settings/MerchantAccountUtils.res | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/screens/Settings/MerchantAccountUtils.res b/src/screens/Settings/MerchantAccountUtils.res index b4168ed63..93ab9c946 100644 --- a/src/screens/Settings/MerchantAccountUtils.res +++ b/src/screens/Settings/MerchantAccountUtils.res @@ -403,14 +403,6 @@ let validateCustom = (key, errors, value, isLiveMode) => { Dict.set(errors, key->validationFieldsMapper, "Please Enter Valid URL"->JSON.Encode.string) } } - | MaxAutoRetries => - if !RegExp.test(%re("/^(?:[1-5])$/"), value) { - Dict.set( - errors, - key->validationFieldsMapper, - "Please enter integer value from 1 to 5"->JSON.Encode.string, - ) - } | _ => () } } @@ -426,16 +418,24 @@ let validateMerchantAccountForm = ( let valuesDict = values->getDictFromJsonObject fieldsToValidate->Array.forEach(key => { - let val = valuesDict->getJsonObjectFromDict(key->validationFieldsMapper) - - switch val->JSON.Classify.classify { - | String(str) => - switch str->getNonEmptyString { - | Some(str) => key->validateCustom(errors, str, isLiveMode) - | _ => () + switch key { + | MaxAutoRetries => { + let value = getInt(valuesDict, key->validationFieldsMapper, 0) + if !RegExp.test(%re("/^(?:[1-5])$/"), value->Int.toString) { + Dict.set( + errors, + key->validationFieldsMapper, + "Please enter integer value from 1 to 5"->JSON.Encode.string, + ) + } + } + | _ => { + let value = getString(valuesDict, key->validationFieldsMapper, "")->getNonEmptyString + switch value { + | Some(str) => key->validateCustom(errors, str, isLiveMode) + | _ => () + } } - | Number(num) => key->validateCustom(errors, num->Float.toString, isLiveMode) - | _ => () } }) From 9108801555d2e3e03908dbe33f4a52ce9c0503a3 Mon Sep 17 00:00:00 2001 From: Kanika Bansal Date: Thu, 24 Oct 2024 12:52:47 +0530 Subject: [PATCH 5/8] fix: merchant account credentials not shown in profile view (#1626) --- src/container/MerchantAccountContainer.res | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/container/MerchantAccountContainer.res b/src/container/MerchantAccountContainer.res index e85142445..6c9ec2c10 100644 --- a/src/container/MerchantAccountContainer.res +++ b/src/container/MerchantAccountContainer.res @@ -18,8 +18,9 @@ let make = () => { let setUpConnectoreContainer = async () => { try { setScreenState(_ => PageLoaderWrapper.Loading) - - let _ = await fetchMerchantAccountDetails() + if !checkUserEntity([#Profile]) { + let _ = await fetchMerchantAccountDetails() + } if userHasAccess(~groupAccess=ConnectorsView) === Access { if !featureFlagDetails.isLiveMode { let _ = await fetchConnectorListResponse() @@ -67,6 +68,7 @@ let make = () => { }} Option.isNone}> From aac4adabf17e96ef7d02fe91048fca8b668030a8 Mon Sep 17 00:00:00 2001 From: Jeeva Ramachandran <120017870+JeevaRamu0104@users.noreply.github.com> Date: Thu, 24 Oct 2024 15:16:29 +0530 Subject: [PATCH 6/8] chore: Add merchant specific config (#1643) --- config/config.toml | 5 + cypress/e2e/auth/auth.cy.js | 2 +- cypress/start_hyperswitch.sh | 2 +- src/Recoils/HyperswitchAtom.res | 3 + src/entryPoints/FeatureFlagUtils.res | 23 ++ src/entryPoints/HyperSwitchApp.res | 11 +- src/entryPoints/HyperSwitchEntry.res | 2 +- src/entryPoints/SidebarValues.res | 9 +- src/hooks/AuthHooks.res | 6 +- .../Hooks/MerchantSpecificConfigHook.res | 57 +++++ src/server/Server.res | 12 +- src/server/config.mjs | 220 ++++++++++++------ webpack.dev.js | 15 +- 13 files changed, 287 insertions(+), 80 deletions(-) create mode 100644 src/screens/Hooks/MerchantSpecificConfigHook.res diff --git a/config/config.toml b/config/config.toml index 2c1098c21..0a18fa679 100644 --- a/config/config.toml +++ b/config/config.toml @@ -49,3 +49,8 @@ down_time=false tax_processor=true transaction_view=false x_feature_route=false +[default.merchant_config] +[default.merchant_config.new_analytics] +org_ids=[] +merchant_ids=[] +profile_ids=[] diff --git a/cypress/e2e/auth/auth.cy.js b/cypress/e2e/auth/auth.cy.js index d52c4df65..d72dfb8f7 100644 --- a/cypress/e2e/auth/auth.cy.js +++ b/cypress/e2e/auth/auth.cy.js @@ -45,7 +45,7 @@ describe("Auth Module", () => { }); it("sets true the feature flag for magic link and forgot password,then checks auth page back button functioning", () => { - cy.intercept("GET", "merchant-config?domain=default", { + cy.intercept("GET", "/dashboard/config/feature?domain=default", { statusCode: 200, body: { theme: { diff --git a/cypress/start_hyperswitch.sh b/cypress/start_hyperswitch.sh index c1f1e3950..5579379e7 100755 --- a/cypress/start_hyperswitch.sh +++ b/cypress/start_hyperswitch.sh @@ -28,4 +28,4 @@ echo "[network_tokenization_service] section removed from $toml_file." chmod +x /usr/local/bin/docker-compose # Start Docker Compose services in detached mode -docker-compose up -d pg redis-standalone migration_runner hyperswitch-server \ No newline at end of file +docker-compose up -d pg redis-standalone migration_runner hyperswitch-server hyperswitch-web \ No newline at end of file diff --git a/src/Recoils/HyperswitchAtom.res b/src/Recoils/HyperswitchAtom.res index 335b1b54a..e10bc3ef6 100644 --- a/src/Recoils/HyperswitchAtom.res +++ b/src/Recoils/HyperswitchAtom.res @@ -20,6 +20,9 @@ let featureFlagAtom: Recoil.recoilAtom = Recoil.at "featureFlag", JSON.Encode.null->FeatureFlagUtils.featureFlagType, ) +let merchantSpecificConfigAtom: Recoil.recoilAtom< + FeatureFlagUtils.merchantSpecificConfig, +> = Recoil.atom("merchantSpecificConfig", JSON.Encode.null->FeatureFlagUtils.merchantSpecificConfig) let paypalAccountStatusAtom: Recoil.recoilAtom = Recoil.atom( "paypalAccountStatusAtom", PayPalFlowTypes.Connect_paypal_landing, diff --git a/src/entryPoints/FeatureFlagUtils.res b/src/entryPoints/FeatureFlagUtils.res index a73672db8..729b20cd2 100644 --- a/src/entryPoints/FeatureFlagUtils.res +++ b/src/entryPoints/FeatureFlagUtils.res @@ -1,3 +1,9 @@ +type config = { + orgIds: array, + merchantIds: array, + profileIds: array, +} +type merchantSpecificConfig = {newAnalytics: config} type featureFlag = { default: bool, testLiveToggle: bool, @@ -82,3 +88,20 @@ let featureFlagType = (featureFlags: JSON.t) => { } typedFeatureFlag } + +let configMapper = dict => { + open LogicUtils + { + orgIds: dict->getStrArrayFromDict("org_ids", []), + merchantIds: dict->getStrArrayFromDict("merchant_ids", []), + profileIds: dict->getStrArrayFromDict("profile_ids", []), + } +} + +let merchantSpecificConfig = (config: JSON.t) => { + open LogicUtils + let dict = config->getDictFromJsonObject + { + newAnalytics: dict->getDictfromDict("new_analytics")->configMapper, + } +} diff --git a/src/entryPoints/HyperSwitchApp.res b/src/entryPoints/HyperSwitchApp.res index bf06ff8ec..8ae0c0187 100644 --- a/src/entryPoints/HyperSwitchApp.res +++ b/src/entryPoints/HyperSwitchApp.res @@ -18,7 +18,14 @@ let make = () => { let merchantDetailsTypedValue = Recoil.useRecoilValueFromAtom(merchantDetailsValueAtom) let featureFlagDetails = featureFlagAtom->Recoil.useRecoilValueFromAtom let (userGroupACL, setuserGroupACL) = Recoil.useRecoilState(userGroupACLAtom) + + let { + fetchMerchantSpecificConfig, + useIsFeatureEnabledForMerchant, + merchantSpecificConfig, + } = MerchantSpecificConfigHook.useMerchantSpecificConfig() let {fetchUserGroupACL, userHasAccess} = GroupACLHooks.useUserGroupACLHook() + let {userInfo: {orgId, merchantId, profileId, roleId}, checkUserEntity} = React.useContext( UserInfoProvider.defaultContext, ) @@ -42,6 +49,7 @@ let make = () => { setScreenState(_ => PageLoaderWrapper.Loading) setuserGroupACL(_ => None) Window.connectorWasmInit()->ignore + let _ = await fetchMerchantSpecificConfig() let _ = await fetchUserGroupACL() switch url.path->urlPath { | list{"unauthorized"} => RescriptReactRouter.push(appendDashboardPath(~url="/home")) @@ -161,7 +169,8 @@ let make = () => { | list{"new-analytics-payment"} => diff --git a/src/entryPoints/HyperSwitchEntry.res b/src/entryPoints/HyperSwitchEntry.res index 140d665fe..c79afb7f8 100644 --- a/src/entryPoints/HyperSwitchEntry.res +++ b/src/entryPoints/HyperSwitchEntry.res @@ -51,7 +51,7 @@ module HyperSwitchEntryComponent = { let fetchConfig = async () => { try { let domain = HyperSwitchEntryUtils.getSessionData(~key="domain", ~defaultValue="default") - let apiURL = `${GlobalVars.getHostUrlWithBasePath}/config/merchant-config?domain=${domain}` + let apiURL = `${GlobalVars.getHostUrlWithBasePath}/config/feature?domain=${domain}` let res = await fetchDetails(apiURL) let featureFlags = res->FeatureFlagUtils.featureFlagType setFeatureFlag(_ => featureFlags) diff --git a/src/entryPoints/SidebarValues.res b/src/entryPoints/SidebarValues.res index 8c8537a0b..0ad447a0a 100644 --- a/src/entryPoints/SidebarValues.res +++ b/src/entryPoints/SidebarValues.res @@ -655,7 +655,12 @@ let useGetSidebarValues = (~isReconEnabled: bool) => { taxProcessor, newAnalytics, } = featureFlagDetails - + let { + useIsFeatureEnabledForMerchant, + merchantSpecificConfig, + } = MerchantSpecificConfigHook.useMerchantSpecificConfig() + let isNewAnalyticsEnable = + newAnalytics && useIsFeatureEnabledForMerchant(merchantSpecificConfig.newAnalytics) let sidebar = [ productionAccessComponent(quickStart, userHasAccess), default->home, @@ -674,7 +679,7 @@ let useGetSidebarValues = (~isReconEnabled: bool) => { authenticationAnalyticsFlag, disputeAnalytics, performanceMonitorFlag, - newAnalytics, + isNewAnalyticsEnable, ~userHasAccess, ), default->workflow(isSurchargeEnabled, ~userHasAccess, ~isPayoutEnabled=payOut, ~userEntity), diff --git a/src/hooks/AuthHooks.res b/src/hooks/AuthHooks.res index 29ecab6c7..9aa076eab 100644 --- a/src/hooks/AuthHooks.res +++ b/src/hooks/AuthHooks.res @@ -1,6 +1,10 @@ type contentType = Headers(string) | Unknown let headersForXFeature = (~uri, ~headers) => { - if uri->String.includes("lottie-files") || uri->String.includes("config/merchant-access") { + if ( + uri->String.includes("lottie-files") || + uri->String.includes("config/merchant") || + uri->String.includes("config/feature") + ) { headers->Dict.set("Content-Type", `application/json`) } else { headers->Dict.set("x-feature", "integ-custom") diff --git a/src/screens/Hooks/MerchantSpecificConfigHook.res b/src/screens/Hooks/MerchantSpecificConfigHook.res new file mode 100644 index 000000000..34f942409 --- /dev/null +++ b/src/screens/Hooks/MerchantSpecificConfigHook.res @@ -0,0 +1,57 @@ +/** + * @module MerchantSpecificConfigHook + * + * @description This exposes a hook to fetch the merchant specific config + * and to check if the merchant has access to config + * + * @functions + * - fetchMerchantSpecificConfig : fetches the list of user group level access + * - useIsFeatureEnabledForMerchant: checks if the merchant has access + * @params + * - config : merchant config + * + * + * + */ +type useMerchantSpecificConfig = { + fetchMerchantSpecificConfig: unit => promise, + useIsFeatureEnabledForMerchant: FeatureFlagUtils.config => bool, + merchantSpecificConfig: FeatureFlagUtils.merchantSpecificConfig, +} + +let useMerchantSpecificConfig = () => { + open APIUtils + let updateAPIHook = useUpdateMethod(~showErrorToast=false) + let setMerchantSpecificConfig = + HyperswitchAtom.merchantSpecificConfigAtom->Recoil.useSetRecoilState + let {userInfo: {orgId, merchantId, profileId}} = React.useContext(UserInfoProvider.defaultContext) + let merchantSpecificConfig = + HyperswitchAtom.merchantSpecificConfigAtom->Recoil.useRecoilValueFromAtom + let fetchMerchantSpecificConfig = async () => { + try { + let domain = HyperSwitchEntryUtils.getSessionData(~key="domain", ~defaultValue="default") + let merchantConfigURL = ` ${GlobalVars.getHostUrlWithBasePath}/config/merchant?domain=${domain}` + let body = + [ + ("org_id", orgId->JSON.Encode.string), + ("merchant_id", merchantId->JSON.Encode.string), + ("profile_id", profileId->JSON.Encode.string), + ]->LogicUtils.getJsonFromArrayOfJson + let response = await updateAPIHook(merchantConfigURL, body, Post) + let mapMerchantSpecificConfig = response->FeatureFlagUtils.merchantSpecificConfig + setMerchantSpecificConfig(_ => mapMerchantSpecificConfig) + } catch { + | Exn.Error(e) => { + let err = Exn.message(e)->Option.getOr("Failed to Fetch!") + Exn.raiseError(err) + } + } + } + let useIsFeatureEnabledForMerchant = (config: FeatureFlagUtils.config) => { + config.orgIds->Array.length > 0 || + config.merchantIds->Array.length > 0 || + config.profileIds->Array.length > 0 + } + + {fetchMerchantSpecificConfig, useIsFeatureEnabledForMerchant, merchantSpecificConfig} +} diff --git a/src/server/Server.res b/src/server/Server.res index 3c7739d37..9397f95a9 100644 --- a/src/server/Server.res +++ b/src/server/Server.res @@ -12,6 +12,10 @@ open NodeJs external configHandler: (Http.request, Http.response, bool, string, string) => unit = "configHandler" +@module("./config.mjs") +external merchantConfigHandler: (Http.request, Http.response, bool, string, string) => unit = + "merchantConfigHandler" + @module("./health.mjs") external healthHandler: (Http.request, Http.response) => unit = "healthHandler" @@ -69,7 +73,13 @@ let serverHandler: Http.serverHandler = (request, response) => { ->String.replaceRegExp(%re("/^\/\//"), "/") ->String.replaceRegExp(%re("/^\/v4\//"), "/") - if path->String.includes("/config/merchant-config") && request.method === "GET" { + if path->String.includes("/config/merchant") && request.method === "POST" { + let path = env->Dict.get("configPath")->Option.getOr("dist/server/config/config.toml") + Promise.make((resolve, _reject) => { + merchantConfigHandler(request, response, true, domain, path) + ()->(resolve(_)) + }) + } else if path->String.includes("/config/feature") && request.method === "GET" { let path = env->Dict.get("configPath")->Option.getOr("dist/server/config/config.toml") Promise.make((resolve, _reject) => { configHandler(request, response, true, domain, path) diff --git a/src/server/config.mjs b/src/server/config.mjs index e901e854f..8e391cf86 100644 --- a/src/server/config.mjs +++ b/src/server/config.mjs @@ -1,95 +1,173 @@ import * as Fs from "fs"; import toml from "@iarna/toml"; -function updateConfigWithEnv(updatedConfig, domain = "", prefix = "") { - for (const key in updatedConfig) { - if (typeof updatedConfig[key] === "object" && updatedConfig[key] !== null) { - updateConfigWithEnv(updatedConfig[key], domain, key); // Recursively update nested objects +// Helper function for error handling +const errorHandler = (res, errorMessage = "something went wrong") => { + res.writeHead(500, { "Content-Type": "text/plain" }); + console.error(errorMessage); + res.end("Internal Server Error"); +}; + +// Update config with environment variables +function updateConfigWithEnv(config, domain = "", prefix = "") { + for (const key in config) { + if (typeof config[key] === "object" && config[key] !== null) { + updateConfigWithEnv(config[key], domain, key); // Recursively update nested objects } else { - // Check if environment variable exists for the key const envVar = process.env[`${domain}__${prefix}__${key}`]; if (envVar !== undefined) { - // Convert string to appropriate type if necessary (e.g., "true" to true) - if (typeof updatedConfig[key] === "boolean") { - updatedConfig[key] = envVar.toLowerCase() === "true"; - } else if (typeof updatedConfig[key] === "number") { - updatedConfig[key] = parseFloat(envVar); - } else { - updatedConfig[key] = envVar; - } + config[key] = inferType(config[key], envVar); // Convert string to appropriate type } } } - return updatedConfig; + + return config; } -const errorHandler = (res, errorMessage = "something went wrong") => { - res.writeHead(500, { "Content-Type": "text/plain" }); - console.log(errorMessage); - res.end("Internal Server Error"); +// Infer type based on the original value's type +const inferType = (originalValue, envValue) => { + if (typeof originalValue === "boolean") + return envValue.toLowerCase() === "true"; + if (typeof originalValue === "number") return parseFloat(envValue); + return envValue; }; -// Main function to read TOML file, override config, and output the result -const configHandler = ( - _req, - res, - is_deployed = false, - domain = "default", - configPath = "dist/server/config/config.toml", -) => { - let configFile = is_deployed ? configPath : "config/config.toml"; - try { - Fs.readFile(configFile, { encoding: "utf8" }, (err, data) => { +// Check if env values exist or fall back to TOML config +function checkEnvValues(env, tomlConfig) { + if (typeof env === "string") { + return env.split(","); + } + if (typeof tomlConfig === "object" && tomlConfig !== null) { + return tomlConfig; + } + return []; +} + +// Update merchant config using environment variables +function updateMerchantConfigWithEnv(tomlConfig, body, domain = "default") { + let modifiedConfig = {}; + for (const key in tomlConfig) { + const envOrgIds = + process.env[`${domain}__merchant_config__${key}__org_ids`]; + const envMerchantIds = + process.env[`${domain}__merchant_config__${key}__merchant_ids`]; + const envProfileIds = + process.env[`${domain}__merchant_config__${key}__profile_ids`]; + + const orgIds = checkEnvValues(envOrgIds, tomlConfig[key].org_ids).filter( + (id) => body.org_id === id, + ); + const merchantIds = checkEnvValues( + envMerchantIds, + tomlConfig[key].merchant_ids, + ).filter((id) => body.org_id === id); + const profileIds = checkEnvValues( + envProfileIds, + tomlConfig[key].profile_ids, + ).filter((id) => body.org_id === id); + + modifiedConfig[key] = { + org_ids: orgIds, + merchant_ids: merchantIds, + profile_ids: profileIds, + }; + } + return modifiedConfig; +} + +// Read and parse TOML config file +const readTomlConfig = (configPath, res) => { + return new Promise((resolve, reject) => { + Fs.readFile(configPath, { encoding: "utf8" }, (err, data) => { if (err) { errorHandler(res, "Error on Reading File"); - return; - } - let config; - try { - config = toml.parse(data); - } catch { - errorHandler(res, "Error on Parsing toml"); - return; + return reject(err); } - let merchantConfig = config["default"]; try { - // If the domain is present in the toml file - if ( - domain.length > 0 && - config[domain] != undefined && - Object.keys(config[domain]).length > 0 - ) { - merchantConfig = updateConfigWithEnv(config[domain], domain, "theme"); - } - // If the domain not is present in the toml file but need to overide the default value with the theme set in the env - else if (domain && domain.length > 0 && domain !== undefined) { - merchantConfig = updateConfigWithEnv( - config["default"], - domain, - "theme", - ); - } else { - merchantConfig = updateConfigWithEnv("default", "", merchantConfig); - } - } catch { - errorHandler(res, "Error on Overding ENV"); - return; - } - if (typeof merchantConfig === "object") { - res.writeHead(200, { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - }); - res.write(JSON.stringify(merchantConfig)); - res.end(); - } else { - errorHandler(res, "Error on sending response"); - return; + resolve(toml.parse(data)); + } catch (err) { + errorHandler(res, "Error on Parsing TOML"); + reject(err); } }); + }); +}; + +// Main config handler +const configHandler = async ( + req, + res, + isDeployed = false, + domain = "default", + configPath = "dist/server/config/config.toml", +) => { + const filePath = isDeployed ? configPath : "config/config.toml"; + try { + const config = await readTomlConfig(filePath, res); + let merchantConfig = config.default; + + if (config[domain] && Object.keys(config[domain]).length > 0) { + merchantConfig = updateConfigWithEnv(config[domain], domain, "theme"); + } else if (domain.length > 0) { + merchantConfig = updateConfigWithEnv(config.default, domain, "theme"); + } else { + merchantConfig = updateConfigWithEnv(merchantConfig, "default", ""); + } + if (merchantConfig && merchantConfig["merchant_config"]) { + delete merchantConfig["merchant_config"]; + } + + res.writeHead(200, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }); + res.end(JSON.stringify(merchantConfig)); } catch (error) { errorHandler(res, "Server Error"); } }; -export { configHandler }; +/* +@module getRequestBody + +To Parse the request body + +*/ + +// Get request body +const getRequestBody = (req) => { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => (body += chunk.toString())); + req.on("end", () => resolve(JSON.parse(body))); + req.on("error", reject); + }); +}; + +// Merchant config handler +const merchantConfigHandler = async ( + req, + res, + isDeployed = false, + domain = "default", + configPath = "dist/server/config/config.toml", +) => { + const filePath = isDeployed ? configPath : "config/config.toml"; + try { + const body = await getRequestBody(req); + const config = await readTomlConfig(filePath, res); + const merchantConfig = + config[domain]?.merchant_config || config.default.merchant_config; + const data = updateMerchantConfigWithEnv(merchantConfig, body, domain); + + res.writeHead(200, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }); + res.end(JSON.stringify(data)); + } catch (error) { + console.log(error); + errorHandler(res, "Server Error"); + } +}; +export { configHandler, merchantConfigHandler }; diff --git a/webpack.dev.js b/webpack.dev.js index 7c5d50ead..f588b7340 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -11,7 +11,7 @@ let port = 9000; let proxy = {}; let configMiddleware = (req, res, next) => { - if (req.path.includes("/config/merchant-config") && req.method == "GET") { + if (req.path.includes("/config/feature") && req.method == "GET") { let { domain = "default" } = req.query; config .then((result) => { @@ -24,6 +24,19 @@ let configMiddleware = (req, res, next) => { }); return; } + if (req.path.includes("/config/merchant") && req.method == "POST") { + let { domain = "default" } = req.query; + config + .then((result) => { + result.merchantConfigHandler(req, res, false, domain); + }) + .catch((error) => { + console.log(error, "error"); + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Internal Server Error"); + }); + return; + } next(); }; From fc44a6a650cdbc001f40148662527909911437aa Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Oct 2024 00:26:30 +0000 Subject: [PATCH 7/8] chore(version): 2024.10.25.0 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a02d3249d..4c030976f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file. See [conven - - - +## 2024.10.25.0 + +### Bug Fixes + +- Auto retry bug ([#1639](https://github.com/juspay/hyperswitch-control-center/pull/1639)) ([`46f4de6`](https://github.com/juspay/hyperswitch-control-center/commit/46f4de64f0c68e587a62c876b56e94a5e25ec26d)) +- Merchant account credentials not shown in profile view ([#1626](https://github.com/juspay/hyperswitch-control-center/pull/1626)) ([`9108801`](https://github.com/juspay/hyperswitch-control-center/commit/9108801555d2e3e03908dbe33f4a52ce9c0503a3)) + +### Miscellaneous Tasks + +- TwoFa restriction after multiple failed attempts before login ([#1594](https://github.com/juspay/hyperswitch-control-center/pull/1594)) ([`9ff488b`](https://github.com/juspay/hyperswitch-control-center/commit/9ff488b8edc99af45fc8f77ab1f56e6cef34a838)) +- Add merchant specific config ([#1643](https://github.com/juspay/hyperswitch-control-center/pull/1643)) ([`aac4ada`](https://github.com/juspay/hyperswitch-control-center/commit/aac4adabf17e96ef7d02fe91048fca8b668030a8)) + +**Full Changelog:** [`2024.10.24.0...2024.10.25.0`](https://github.com/juspay/hyperswitch-control-center/compare/2024.10.24.0...2024.10.25.0) + +- - - + ## 2024.10.24.0 ### Testing From d910c1e649ac7ae78f5d7cf02a20769abb5e17eb Mon Sep 17 00:00:00 2001 From: Riddhiagrawal001 <50551695+Riddhiagrawal001@users.noreply.github.com> Date: Mon, 28 Oct 2024 10:52:42 +0530 Subject: [PATCH 8/8] fix: changes to enable card-network (#1655) Co-authored-by: Pritish Budhiraja --- src/screens/Order/OrderUIUtils.res | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/screens/Order/OrderUIUtils.res b/src/screens/Order/OrderUIUtils.res index 721e36154..51bd76bb4 100644 --- a/src/screens/Order/OrderUIUtils.res +++ b/src/screens/Order/OrderUIUtils.res @@ -274,11 +274,7 @@ let initialFilters = (json, filtervalues) => { let connectorFilter = filtervalues->getArrayFromDict("connector", [])->getStrArrayFromJsonArray - // TODO: Remove the card-network delete once card-network issue is fixed - let filterDict = - json - ->getDictFromJsonObject - ->DictionaryUtils.deleteKeys(["card_network"]) + let filterDict = json->getDictFromJsonObject let filterArr = filterDict->itemToObjMapper let arr = filterDict->Dict.keysToArray