diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..d21bedc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,17 @@ +--- +name: Bug report +about: Tell us about a bug you found +title: "" +labels: bug +assignees: "" +--- + + + +### What version are you using? + +### What did you do? + +### What happened? + +### What did you expect to see instead? diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..60d9c1e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: true +contact_links: + - name: Backend Project + url: https://github.com/stellar/stellar-disbursement-platform-backend + about: The backend API where this repository's application integrates with. + - name: Stellar Laboratory + url: https://laboratory.stellar.org/#?network=test + about: The best place to experiment with the Stellar network. + - name: Docker Images + url: https://hub.docker.com/r/stellar/stellar-disbursement-platform-frontend + about: Where to check the available Docker images that have been published. + - name: Stellar Ecosystem Proposals (SEPs) + url: https://github.com/stellar/stellar-protocol + about: The SEPs implemented in this project are defined here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..0ff1c82 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Tell us what you'd like to see +title: "Feature Request: " +labels: "" +assignees: "" +--- + + + +### What problem does your feature solve? + +### What would you like to see? + +### What alternatives are there? diff --git a/.github/ISSUE_TEMPLATE/release_a_new_version.md b/.github/ISSUE_TEMPLATE/release_a_new_version.md new file mode 100644 index 0000000..5df14b7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release_a_new_version.md @@ -0,0 +1,57 @@ +--- +name: Release a New Version! +about: Prepare a release to be launched +title: "" +labels: release +--- + + + +## Release Checklist + +> Attention: the examples below use the version `x.y.z` but you should update +> them to use the version you're releasing. + +### Git Preparation + +- [ ] Decide on a version number based on the current version number and the + common rules defined in [Semantic Versioning](https://semver.org). E.g. + `x.y.z`. +- [ ] Update this ticket name to reflect the new version number, following the + pattern "Release `x.y.z`". +- [ ] Cut a branch for the new release out of the `develop` branch, following + the gitflow naming pattern `release/x.y.z`. + +### Code Preparation + +- [ ] Update the project's version in [package.json] accordingly. +- [ ] Update the [CHANGELOG.md] with the new version number and release notes. +- [ ] Run tests and linting, and make sure the version running in the default + branch is working end-to-end. At least the minimal end-to-end manual tests + is mandatory. +- [ ] 🚨 DO NOT RELEASE before holidays or weekends! Mondays and Tuesdays are + preferred. + +### Merging the Branches + +> 🚨 ATTENTION: in the following steps, do `merge commits` and NOT +> `squash-and-merge`! + +- [ ] When the team is confident the release is stable, you'll need to create + two pull requests: + - [ ] `release/x.y.z -> main`: This PR should be merged with a merge commit. + - [ ] `release/x.y.z -> develop`: this should be merged after the `main` + branch is merged. This PR should be merged with a merge commit. + +### Publishing the Release + +- [ ] After the release branch is merged to `main`, create a new release on + GitHub with the name `x.y.z` and the use the same changes from the + [CHANGELOG.md] file. + - [ ] The release should automatically publish a new version of the docker + image to Docker Hub. Double check if that happened. + +[package.json]: + https://github.com/stellar/stellar-disbursement-platform-frontend/blob/develop/package.json +[CHANGELOG.md]: + https://github.com/stellar/stellar-disbursement-platform-frontend/blob/develop/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c41e16d..1e6cfe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,24 +2,57 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [1.0.0](https://github.com/stellar/stellar-relief-backoffice/tree/1.0.0) +## Unreleased + +> Place unreleased changes here. + +## [1.0.0.rc2](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.0.0-rc1...1.0.0-rc2) ### Added -- The payment detail to display the cash-out status per payment. -- The account status to be able to track the cash-out status per account. +- Support for a 2-step approval for the disbursement, where one user creates the + disbursement and another approves it. + [#1](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/1), + [#3](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/3) +- Support to edit receivers. + [#5](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/5) +- Support for changing the password without resorting to the "fogot password" + flow. + [#6](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/6) -### Updated +### Changed -- The account & disbursement detail pages to display the amount cashed-out per - account and per disbursement. +- Readme instructions. + [#2](https://github.com/stellar/stellar-disbursement-platform-frontend/pull/2) -## {version} < 1.0.0 +## [1.0.0.rc1](https://github.com/stellar/stellar-disbursement-platform-frontend/releases/tag/1.0.0-rc1) -### Added: +### Added -- Disbursements list & detail. -- Beneficiaries (accounts) list & detail. -- Payments per disbursement detail and per account detail. +First Release Candidate of the Stellar Disbursement Platform, a tool used to +make bulk payments to a list of recipients based on their phone number and a +confirmation date. This repository is frontend-only, is a client to the backend +version available at [stellar/stellar-disbursement-platform-backend]. Their +version numbers are meant to be kept in sync. + +The basic process of this product starts with an organization supplying a CSV +file which includes the recipients' phone number, transfer amount, and essential +customer validation data such as the date of birth. + +The platform subsequently sends an SMS to the recipient, which includes a deep +link to the wallet. This link permits recipients with compatible wallets to +register their wallet on the SDP. During this step, they are required to verify +their phone number and additional customer data through the SEP-24 interactive +deposit flow, where this data is shared directly with the backend through a +webpage inside the wallet, but the wallet itself does not have access to this +data. + +Upon successful verification, the SDP backend will transfer the funds directly +to the recipient's wallet. When the recipient's wallet has been successfully +associated with their phone number in the SDP, all subsequent payments will +occur automatically. + +[stellar/stellar-disbursement-platform-backend]: + https://github.com/stellar/stellar-disbursement-platform-backend diff --git a/README.md b/README.md index 0237f05..35e71a7 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,30 @@ -# stellar-relief-backoffice +# Stellar Disbursement Platform Frontend -## Add `/public/settings/env-config.js` file locally with the following keys: +## Introduction -### SSO +The Stellar Disbursement Platform (SDP) enables organizations to disburse bulk payments to recipients using Stellar. -- USE_SSO - variable for switch to current/old Login or to SSO Login; -- If you are going to use SSO - you need provide OIDC_REDIRECT_URI to specialist - who will configure OIDC Provider and get from them OIDC_AUTHORITY, - OIDC_CLIENT_ID, OIDC_SCOPE, OIDC_USERNAME_MAPPING; -- OIDC_USERNAME_MAPPING - is using for show in web page in "username" field; -- Options of OIDC_USERNAME_MAPPING you could find in ID Token body (in user - claims) or in OIDC Provider configure page; -- When you will switch to using SSO - it must be synchronously changed in - BackEnd side - as in current moment its possible to use only tokens from one - issuer; +This repo contains the SDP dashboard UI, which is to be used with the [Stellar Disbursement Platform Backend](https://github.com/stellar/stellar-disbursement-platform-backend). For more information on how to get started, see the Stellar [dev docs](https://developers.stellar.org/docs/category/use-the-stellar-disbursement-platform) and [API reference](https://developers.stellar.org/api/stellar-disbursement-platform). + +The SDP's comprehensive dashboard includes the following pages: +* Dashboard Home (Overview): Summary of recent disbursement activities and key metrics, including successful payment rate, total successful/failed/remaining payments, total disbursed, individuals, and wallets. +* Disbursements Page (Management): Create, draft, search, filter, and export disbursements. Detailed disbursement page includes names, total payments, successes, failures, remaining, creation date, total amount, and disbursed amount. +* Receivers Page (Overview): List of individuals set to receive payments, with wallet information and payment history. May also search, filter, and export receiver data in CSV. +* Payments Page (Overview): Summary of all payments, including search by payment ID, filters, and export options. Payment detail includes Payment ID, wallet address, disbursement name, completion time, amount, and status information. +* Wallets Page (Management): View Distribution Account information including public key, balance, adding funds, and more, and manage which assets you want to use on the Stellar network. +* Analytics Page (Overview): Provides insights into financial transactions, including successful payment rate, total successful/failed/remaining payments, total disbursed, average amount, total amount per asset, and individuals and wallets involved. + +Feedback and contributions are welcome! + +### Config + +Make sure to set the following for initial local testing: ```javascript window._env_ = { - API_URL: "", - STELLAR_EXPERT_URL: "", - HORIZON_URL: "", - USDC_ASSET_ISSUER: "", - RECAPTCHA_SITE_KEY: "", - - USE_SSO: false, - OIDC_AUTHORITY: - "https://.b2clogin.com/.onmicrosoft.com/", - OIDC_CLIENT_ID: "", - OIDC_REDIRECT_URI: "http://localhost:3000/signin-oidc", - OIDC_SCOPE: "openid", - OIDC_USERNAME_MAPPING: "name", -}; + API_URL: "https://localhost:8000", + STELLAR_EXPERT_URL: "https://stellar.expert/explorer/testnet", + HORIZON_URL: "https://horizon-testnet.stellar.org", + RECAPTCHA_SITE_KEY: "6Lego1wmAAAAAJNwh6RoOrsHuWnsciCTIL3NN-bn", +}; ``` diff --git a/package.json b/package.json index 2faa6b1..49f8661 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "stellar-relief-backoffice", - "version": "1.0.0", + "name": "stellar-disbursement-platform-frontend", + "version": "1.0.0-rc2", "license": "Apache-2.0", "engines": { "node": ">=18.x" @@ -77,7 +77,7 @@ "production": "yarn prod:build && yarn prod:serve", "prepare": "husky install", "pre-commit": "concurrently 'pretty-quick --staged' 'lint-staged' 'tsc --noEmit'", - "git-info": "rm -rf src/generated/ && mkdir src/generated/ && echo export default \"{\\\"commitHash\\\": \\\"$(git rev-parse --short HEAD)\\\"};\" > src/generated/gitInfo.ts" + "git-info": "rm -rf src/generated/ && mkdir src/generated/ && echo export default \"{\\\"commitHash\\\": \\\"$(git rev-parse --short HEAD)\\\", \\\"version\\\": \\\"$(git describe --tags --always)\\\"};\" > src/generated/gitInfo.ts" }, "eslintConfig": { "extends": [ diff --git a/src/App.tsx b/src/App.tsx index 7e340da..3bf8224 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { SignIn } from "pages/SignIn"; import { MFAuth } from "pages/MFAuth"; import { ForgotPassword } from "pages/ForgotPassword"; import { ResetPassword } from "pages/ResetPassword"; +import { SetNewPassword } from "pages/SetNewPassword"; import { Home } from "pages/Home"; import { Disbursements } from "pages/Disbursements"; import { DisbursementDetails } from "pages/DisbursementDetails"; @@ -22,6 +23,7 @@ import { DisbursementDraftDetails } from "pages/DisbursementDraftDetails"; import { DisbursementsDrafts } from "pages/DisbursementsDrafts"; import { Receivers } from "pages/Receivers"; import { ReceiverDetails } from "pages/ReceiverDetails"; +import { ReceiverDetailsEdit } from "pages/ReceiverDetailsEdit"; import { PaymentDetails } from "pages/PaymentDetails"; import { Payments } from "pages/Payments"; import { Wallets } from "pages/Wallets"; @@ -49,6 +51,7 @@ export const App = () => { useEffect(() => { // Git commit hash console.log("current commit hash: ", GitInfo.commitHash); + console.log("version: ", GitInfo.version); }, []); return ( @@ -84,6 +87,15 @@ export const App = () => { } /> + {/* Reset password (authenticated user) */} + + + + } + /> {/* 2FA Verification */} { } /> + + + + + + } + /> {/* Payments */} => { + const fieldsToSubmit = sanitizeObject({ + current_password: fields.currentPassword, + new_password: fields.newPassword, + }); + + if (Object.keys(fieldsToSubmit).length < 2) { + throw Error( + "Update profile password requires current password and new password.", + ); + } + + const response = await fetch(`${API_URL}/profile/reset-password`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(fieldsToSubmit), + }); + + return handleApiResponse(response); +}; diff --git a/src/api/patchReceiver.ts b/src/api/patchReceiver.ts new file mode 100644 index 0000000..29bccc7 --- /dev/null +++ b/src/api/patchReceiver.ts @@ -0,0 +1,31 @@ +import { handleApiResponse } from "api/handleApiResponse"; +import { API_URL } from "constants/settings"; +import { sanitizeObject } from "helpers/sanitizeObject"; + +export const patchReceiverInfo = async ( + token: string, + receiverId: string, + fields: { + email: string; + externalId: string; + }, +): Promise<{ message: string }> => { + const fieldsToSubmit = sanitizeObject({ + email: fields.email, + external_id: fields.externalId, + }); + + if (Object.keys(fieldsToSubmit).length === 0) { + throw Error("Update receiver info requires at least one field to submit"); + } + + const response = await fetch(`${API_URL}/receivers/${receiverId}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(fieldsToSubmit), + }); + + return handleApiResponse(response); +}; diff --git a/src/components/DisbursementButtons/index.tsx b/src/components/DisbursementButtons/index.tsx index 8090705..5368946 100644 --- a/src/components/DisbursementButtons/index.tsx +++ b/src/components/DisbursementButtons/index.tsx @@ -17,6 +17,7 @@ interface DisbursementButtonsPros { isSubmitDisabled?: boolean; isDraftPending?: boolean; actionType?: DisbursementDraftAction; + tooltip?: string; } export const DisbursementButtons = ({ @@ -32,6 +33,7 @@ export const DisbursementButtons = ({ isSubmitDisabled, isDraftPending, actionType, + tooltip, }: DisbursementButtonsPros) => { const navigate = useNavigate(); @@ -183,6 +185,7 @@ export const DisbursementButtons = ({ type="submit" disabled={isSubmitDisabled || isSavePending} isLoading={isSubmitPending} + {...(tooltip ? { title: tooltip } : {})} > Confirm disbursement diff --git a/src/components/DisbursementDetails/index.tsx b/src/components/DisbursementDetails/index.tsx index 1db0bc2..01874c0 100644 --- a/src/components/DisbursementDetails/index.tsx +++ b/src/components/DisbursementDetails/index.tsx @@ -51,6 +51,7 @@ const initDetails: Disbursement = { }, createdAt: "", status: "DRAFT", + statusHistory: [], }; export const DisbursementDetails: React.FC = ({ diff --git a/src/constants/settings.ts b/src/constants/settings.ts index 9cf9628..c1090a4 100644 --- a/src/constants/settings.ts +++ b/src/constants/settings.ts @@ -27,10 +27,12 @@ export enum Routes { HOME = "/home", FORGOT_PASSWORD = "/forgot-password", RESET_PASSWORD = "/reset-password", + SET_NEW_PASSWORD = "/set-password", DISBURSEMENTS = "/disbursements", DISBURSEMENT_NEW = "/disbursements/new", DISBURSEMENT_DRAFTS = "/disbursements/drafts", RECEIVERS = "/receivers", + RECEIVERS_EDIT = "/receivers/edit", PAYMENTS = "/payments", WALLETS = "/wallets", ANALYTICS = "/analytics", diff --git a/src/generated/gitInfo.ts b/src/generated/gitInfo.ts index e3eacf8..74726f6 100644 --- a/src/generated/gitInfo.ts +++ b/src/generated/gitInfo.ts @@ -1 +1 @@ -export default { commitHash: "b6fa31e" }; +export default { commitHash: "5386816", version: "1.0.0-rc2" }; diff --git a/src/helpers/formatDisbursements.ts b/src/helpers/formatDisbursements.ts index 83c8d4e..c04ad09 100644 --- a/src/helpers/formatDisbursements.ts +++ b/src/helpers/formatDisbursements.ts @@ -35,4 +35,9 @@ export const formatDisbursement = ( name: disbursement.wallet.name, }, fileName: disbursement.file_name, + statusHistory: disbursement.status_history.map((h) => ({ + status: h.status, + timestamp: h.timestamp, + userId: h.user_id, + })), }); diff --git a/src/helpers/sanitizeObject.ts b/src/helpers/sanitizeObject.ts index 0e450db..27e0d76 100644 --- a/src/helpers/sanitizeObject.ts +++ b/src/helpers/sanitizeObject.ts @@ -2,5 +2,5 @@ import { AnyObject } from "types"; export const sanitizeObject = (object: AnyObject) => Object.entries(object) - .filter(([, val]) => Boolean(val)) + .filter(([, val]) => (typeof val === "boolean" ? true : Boolean(val))) .reduce((obj, [key, val]) => ({ ...obj, [key]: val }), {}); diff --git a/src/helpers/validateNewPassword.ts b/src/helpers/validateNewPassword.ts new file mode 100644 index 0000000..1c2cee9 --- /dev/null +++ b/src/helpers/validateNewPassword.ts @@ -0,0 +1,18 @@ +export const validateNewPassword = (password: string): string => { + const passwordStrength = new RegExp( + "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})", + ); + + let errorMsg = ""; + + if (!password) { + errorMsg = "Password is required"; + } else if (password.length < 8) { + errorMsg = "Password must be at least 8 characters long"; + } else if (!passwordStrength.test(password)) { + errorMsg = + "Password must have at least one uppercase letter, lowercase letter, number, and symbol."; + } + + return errorMsg; +}; diff --git a/src/helpers/validatePasswordMatch.ts b/src/helpers/validatePasswordMatch.ts new file mode 100644 index 0000000..14be6f8 --- /dev/null +++ b/src/helpers/validatePasswordMatch.ts @@ -0,0 +1,13 @@ +export const validatePasswordMatch = ( + password: string, + confirmPassword: string, +): string => { + let errorMsg = ""; + if (confirmPassword) { + errorMsg = password === confirmPassword ? "" : "Passwords don't match"; + } else { + errorMsg = "Confirm password is required"; + } + + return errorMsg; +}; diff --git a/src/pages/DisbursementDraftDetails.tsx b/src/pages/DisbursementDraftDetails.tsx index 201b39e..4ed756b 100644 --- a/src/pages/DisbursementDraftDetails.tsx +++ b/src/pages/DisbursementDraftDetails.tsx @@ -11,10 +11,12 @@ import { setDisbursementDetailsAction, } from "store/ducks/disbursementDetails"; import { + clearCsvUpdatedAction, clearDisbursementDraftsErrorAction, resetDisbursementDraftsAction, + saveNewCsvFileAction, setDraftIdAction, - submitDisbursementDraftAction, + submitDisbursementSavedDraftAction, } from "store/ducks/disbursementDrafts"; import { Breadcrumbs } from "components/Breadcrumbs"; @@ -29,14 +31,24 @@ import { DisbursementDraft, DisbursementStep } from "types"; export const DisbursementDraftDetails = () => { const { id: draftId } = useParams(); - const { disbursements, disbursementDrafts, disbursementDetails } = useRedux( + const { + disbursements, + disbursementDrafts, + disbursementDetails, + organization, + profile, + } = useRedux( "disbursements", "disbursementDrafts", "disbursementDetails", + "organization", + "profile", ); const [draftDetails, setDraftDetails] = useState(); const [csvFile, setCsvFile] = useState(); + const [isCsvFileUpdated, setIsCsvFileUpdated] = useState(false); + const [isCsvUpdatedSuccess, setIsCsvUpdatedSuccess] = useState(false); const [currentStep, setCurrentStep] = useState("preview"); const [isDraftInProgress, setIsDraftInProgress] = useState(false); @@ -108,23 +120,52 @@ export const DisbursementDraftDetails = () => { setIsResponseSuccess(true); } } + + return () => { + setIsResponseSuccess(false); + dispatch(clearCsvUpdatedAction()); + }; }, [ disbursementDrafts.actionType, disbursementDrafts.newDraftId, disbursementDrafts.status, + dispatch, + ]); + + useEffect(() => { + if ( + disbursementDrafts.isCsvFileUpdated && + disbursementDrafts.status === "SUCCESS" + ) { + setIsDraftInProgress(false); + setIsCsvFileUpdated(false); + setIsCsvUpdatedSuccess(true); + + if (draftId) { + dispatch(getDisbursementDetailsAction(draftId)); + } + } + }, [ + disbursementDrafts.isCsvFileUpdated, + disbursementDrafts.status, + dispatch, + draftId, ]); const resetState = () => { setCurrentStep("edit"); setDraftDetails(undefined); setCsvFile(undefined); + setIsCsvFileUpdated(false); setIsResponseSuccess(false); dispatch(resetDisbursementDraftsAction()); }; const handleSaveDraft = () => { - alert("TODO: save draft"); - setIsDraftInProgress(true); + if (draftId && csvFile) { + dispatch(saveNewCsvFileAction({ savedDraftId: draftId, file: csvFile })); + setIsDraftInProgress(true); + } }; const handleGoBackToDrafts = () => { @@ -138,7 +179,8 @@ export const DisbursementDraftDetails = () => { event.preventDefault(); if (draftDetails && csvFile) { dispatch( - submitDisbursementDraftAction({ + submitDisbursementSavedDraftAction({ + savedDraftId: draftId, details: draftDetails.details, file: csvFile, }), @@ -154,7 +196,27 @@ export const DisbursementDraftDetails = () => { resetState(); }; + const hasUserWorkedOnThisDraft = () => { + return Boolean( + disbursementDetails.details.statusHistory.find( + (h) => h.userId === profile.data.id, + ), + ); + }; + const renderButtons = (variant: DisbursementStep) => { + const canUserSubmit = organization.data.isApprovalRequired + ? // If approval is required, a different user must submit the draft + !isCsvFileUpdated && !hasUserWorkedOnThisDraft() + : true; + + let tooltip; + + if (!canUserSubmit) { + tooltip = + "Your organization requires disbursements to be approved by another user. Save as a draft and make sure another user reviews and submits."; + } + return ( { clearDrafts={() => { dispatch(resetDisbursementDraftsAction()); }} - // TODO: enable when update draft endpoint is ready - isDraftDisabled={true} - isSubmitDisabled={!(Boolean(draftDetails) && Boolean(csvFile))} + isDraftDisabled={!isCsvFileUpdated} + isSubmitDisabled={ + !(Boolean(draftDetails) && Boolean(csvFile) && canUserSubmit) + } isDraftPending={disbursementDrafts.status === "PENDING"} actionType={disbursementDrafts.actionType} + tooltip={tooltip} /> ); }; @@ -229,24 +293,47 @@ export const DisbursementDraftDetails = () => { } return ( -
- - { - if (apiError) { - dispatch(clearDisbursementDraftsErrorAction()); - } - setCsvFile(file); - }} - /> - - {renderButtons("preview")} - + <> + {isCsvUpdatedSuccess ? ( + +
+ Your file was updated successfully. Make sure to confirm your + disbursement to start it. +
+ +
+ { + setIsCsvUpdatedSuccess(false); + }} + > + Dismiss + +
+
+ ) : null} +
+ + { + if (apiError) { + dispatch(clearDisbursementDraftsErrorAction()); + } + setCsvFile(file); + setIsCsvFileUpdated(true); + dispatch(clearCsvUpdatedAction()); + }} + /> + + {renderButtons("preview")} + + ); }; diff --git a/src/pages/DisbursementsNew.tsx b/src/pages/DisbursementsNew.tsx index e203cdc..f52a6df 100644 --- a/src/pages/DisbursementsNew.tsx +++ b/src/pages/DisbursementsNew.tsx @@ -14,7 +14,7 @@ import { clearDisbursementDraftsErrorAction, resetDisbursementDraftsAction, saveDisbursementDraftAction, - submitDisbursementDraftAction, + submitDisbursementNewDraftAction, } from "store/ducks/disbursementDrafts"; import { useRedux } from "hooks/useRedux"; import { useOrgAccountInfo } from "hooks/useOrgAccountInfo"; @@ -127,7 +127,10 @@ export const DisbursementsNew = () => { event.preventDefault(); if (draftDetails && csvFile) { dispatch( - submitDisbursementDraftAction({ details: draftDetails, file: csvFile }), + submitDisbursementNewDraftAction({ + details: draftDetails, + file: csvFile, + }), ); } }; @@ -159,15 +162,21 @@ export const DisbursementsNew = () => { clearDrafts={() => { dispatch(resetDisbursementDraftsAction()); }} - // TODO: update save draft action when endpoint is ready isDraftDisabled={ !isDraftEnabled || Boolean(disbursementDrafts.newDraftId && currentStep === "preview") } - isSubmitDisabled={!(draftDetails && csvFile)} + isSubmitDisabled={ + organization.data.isApprovalRequired || !(draftDetails && csvFile) + } isReviewDisabled={!isReviewEnabled} isDraftPending={disbursementDrafts.status === "PENDING"} actionType={disbursementDrafts.actionType} + tooltip={ + organization.data.isApprovalRequired + ? "Your organization requires disbursements to be approved by another user. Save as a draft and make sure another user reviews and submits." + : undefined + } /> ); }; diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 26c59b0..af957dd 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -10,6 +10,7 @@ import { Link, Select, Notification, + Checkbox, } from "@stellar/design-system"; import { DropdownMenu } from "components/DropdownMenu"; @@ -50,13 +51,14 @@ export const Profile = () => { "owner", "financial_controller", ]); - const [isEditAccount, setIsEditAccount] = useState(false); const [isEditOrganization, setIsEditOrganization] = useState(false); const [imageFile, setImageFile] = useState(); const [imageFileUrl, setImageFileUrl] = useState(); + const [isApprovalRequired, setIsApprovalRequired] = useState(false); const [accountDetails, setAccountDetails] = useState({ + id: "", firstName: "", lastName: "", email: "", @@ -107,6 +109,12 @@ export const Profile = () => { }; }, [dispatch]); + useEffect(() => { + if (organization.data.isApprovalRequired != undefined) { + setIsApprovalRequired(organization.data.isApprovalRequired); + } + }, [organization.data.isApprovalRequired]); + const ImageUploadInput = ({ isReadOnly }: { isReadOnly?: boolean }) => { const getInfoMessage = () => { if (isReadOnly) { @@ -201,6 +209,7 @@ export const Profile = () => { event.preventDefault(); setIsEditAccount(false); setAccountDetails({ + id: profile.data.id, firstName: profile.data.firstName, lastName: profile.data.lastName, email: profile.data.lastName, @@ -214,7 +223,7 @@ export const Profile = () => { ) => { event.preventDefault(); - if (organizationDetails.name || imageFile) { + if (organizationDetails.name || imageFile || isApprovalRequired) { dispatch( updateOrgInfoAction({ name: emptyValueIfNotChanged( @@ -222,6 +231,7 @@ export const Profile = () => { organization.data.name, ), logo: imageFile, + isApprovalRequired, }), ); } @@ -237,13 +247,13 @@ export const Profile = () => { name: organization.data.name, logo: organization.data.logo, }); + setIsApprovalRequired(Boolean(organization.data.isApprovalRequired)); dispatch(orgClearErrorAction()); if (imageFileUrl) { URL.revokeObjectURL(imageFileUrl); } }; - const handleAccountDetailsChange = ( event: React.ChangeEvent, ) => { @@ -276,7 +286,7 @@ export const Profile = () => { }; const goToResetPassword = () => { - navigate(Routes.FORGOT_PASSWORD); + navigate(Routes.SET_NEW_PASSWORD); }; const renderAccount = () => { @@ -406,33 +416,50 @@ export const Profile = () => { } : {})} > -
-
- {isEditOrganization ? ( - <> - - - ) : ( - <> -
- -
- {organization.data.name} -
-
- - )} - - -
+ {isEditOrganization ? ( + <> + + + ) : ( + <> +
+ +
+ {organization.data.name} +
+
+ + )} +
+ + setIsApprovalRequired(e.target.checked)} + /> +
+ {isEditOrganization ? (
@@ -445,7 +472,8 @@ export const Profile = () => { type="submit" disabled={ organizationDetails.name === organization.data.name && - !imageFile + !imageFile && + isApprovalRequired === organization.data.isApprovalRequired } isLoading={profile.status === "PENDING"} > @@ -531,6 +559,7 @@ export const Profile = () => { onClick={() => { setIsEditAccount(true); setAccountDetails({ + id: profile.data.id, firstName: profile.data.firstName, lastName: profile.data.lastName, email: profile.data.email, @@ -564,6 +593,9 @@ export const Profile = () => { name: organization.data.name, logo: organization.data.logo, }); + setIsApprovalRequired( + Boolean(organization.data.isApprovalRequired), + ); }} > Edit details diff --git a/src/pages/ReceiverDetails.tsx b/src/pages/ReceiverDetails.tsx index f9dec19..8810768 100644 --- a/src/pages/ReceiverDetails.tsx +++ b/src/pages/ReceiverDetails.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { useDispatch } from "react-redux"; import { Card, @@ -7,6 +7,7 @@ import { Notification, Profile, Select, + Button, } from "@stellar/design-system"; import { AppDispatch } from "store"; @@ -47,6 +48,7 @@ export const ReceiverDetails = () => { const [selectedWallet, setSelectedWallet] = useState(); const dispatch: AppDispatch = useDispatch(); + const navigate = useNavigate(); const { stats } = receiverDetails; const maxPages = receiverPayments.pagination?.pages || 1; @@ -447,6 +449,17 @@ export const ReceiverDetails = () => { + diff --git a/src/pages/ReceiverDetailsEdit.tsx b/src/pages/ReceiverDetailsEdit.tsx new file mode 100644 index 0000000..ad68823 --- /dev/null +++ b/src/pages/ReceiverDetailsEdit.tsx @@ -0,0 +1,255 @@ +import { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { + Card, + Heading, + Button, + Input, + Icon, + Notification, +} from "@stellar/design-system"; + +import { AppDispatch } from "store"; +import { + getReceiverDetailsAction, + updateReceiverDetailsAction, +} from "store/ducks/receiverDetails"; +import { Routes } from "constants/settings"; +import { useRedux } from "hooks/useRedux"; + +import { Breadcrumbs } from "components/Breadcrumbs"; +import { SectionHeader } from "components/SectionHeader"; +import { CopyWithIcon } from "components/CopyWithIcon"; +import { InfoTooltip } from "components/InfoTooltip"; +import { ReceiverEditFields } from "types"; + +export const ReceiverDetailsEdit = () => { + const { id: receiverId } = useParams(); + + const { receiverDetails } = useRedux("receiverDetails"); + const dispatch: AppDispatch = useDispatch(); + const navigate = useNavigate(); + + const [pin, setPin] = useState(""); + const [dateOfBirth, setDateOfBirth] = useState(""); + const [nationalId, setNationalId] = useState(""); + const [receiverEditFields, setReceiverEditFields] = + useState({ + email: "", + externalId: "", + }); + + useEffect(() => { + if (receiverId) { + dispatch(getReceiverDetailsAction(receiverId)); + } + }, [receiverId, dispatch]); + + useEffect(() => { + setReceiverEditFields({ + email: receiverDetails.email || "", + externalId: receiverDetails.orgId, + }); + }, [receiverDetails.email, receiverDetails.orgId]); + + useEffect(() => { + receiverDetails.verifications.forEach((v) => { + switch (v.verificationField) { + case "DATE_OF_BIRTH": + setDateOfBirth(v.value); + break; + case "PIN": + setPin(v.value); + break; + case "NATIONAL_ID_NUMBER": + setNationalId(v.value); + break; + } + }); + }, [receiverDetails.verifications]); + + const emptyValueIfNotChanged = (newValue: string, oldValue: string) => { + return newValue === oldValue ? "" : newValue; + }; + + const handleReceiverEditSubmit = ( + e: React.MouseEvent, + ) => { + e.preventDefault(); + + const { email, externalId } = receiverEditFields; + + if ((email || externalId) && receiverId) { + dispatch( + updateReceiverDetailsAction({ + receiverId, + email: emptyValueIfNotChanged(email, receiverDetails.email || ""), + externalId: emptyValueIfNotChanged(externalId, receiverDetails.orgId), + }), + ); + } + + if (!receiverDetails.errorString) { + navigate(`${Routes.RECEIVERS}/${receiverId}`); + } + }; + + const handleReceiverEditCancel = ( + e: React.MouseEvent, + ) => { + e.preventDefault(); + setReceiverEditFields({ + email: receiverDetails.email || "", + externalId: receiverDetails.orgId, + }); + navigate(`${Routes.RECEIVERS}/${receiverId}`); + }; + + const handleDetailsChange = (event: React.ChangeEvent) => { + setReceiverEditFields({ + ...receiverEditFields, + [event.target.name]: event.target.value, + }); + }; + + const renderInfoEditContent = () => { + const isSubmitDisabled = + receiverEditFields.email === receiverDetails.email && + receiverEditFields.externalId === receiverDetails.orgId; + + return ( + <> + + + + + + {receiverDetails.phoneNumber} + + + + + + + {receiverDetails.errorString && ( + +
{receiverDetails.errorString}
+
+ )} + +
+ +
+
+ Receiver info +
+ +
+
+
+ + + + + +
+
+
+
+
+
+ + +
+
+ + ); + }; + + return ( + <> + + + {renderInfoEditContent()} + + ); +}; diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx index 0912b60..2f5f0a6 100644 --- a/src/pages/ResetPassword.tsx +++ b/src/pages/ResetPassword.tsx @@ -15,6 +15,8 @@ import { resetPasswordAction, } from "store/ducks/forgotPassword"; import { useRedux } from "hooks/useRedux"; +import { validateNewPassword } from "helpers/validateNewPassword"; +import { validatePasswordMatch } from "helpers/validatePasswordMatch"; export const ResetPassword = () => { const dispatch: AppDispatch = useDispatch(); @@ -51,39 +53,6 @@ export const ResetPassword = () => { navigate("/"); }; - const validatePassword = () => { - const passwordStrength = new RegExp( - "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})", - ); - - let errorMsg = ""; - - if (!password) { - errorMsg = "Password is required"; - } else if (password.length < 8) { - errorMsg = "Password must be at least 8 characters long"; - } else if (!passwordStrength.test(password)) { - errorMsg = - "Password must have at least one uppercase letter, lowercase letter, number, and symbol."; - } - - setErrorPassword(errorMsg); - - if (confirmPassword) { - validatePasswordMatch(); - } - }; - - const validatePasswordMatch = () => { - if (confirmPassword) { - setErrorPasswordMatch( - password === confirmPassword ? "" : "Passwords don't match", - ); - } else { - setErrorPasswordMatch("Confirm password is required"); - } - }; - const validateConfirmationToken = () => { setErrorConfirmationToken( confirmationToken ? "" : "Confirmation token is required", @@ -158,7 +127,15 @@ export const ResetPassword = () => { setErrorPassword(""); setPassword(e.target.value); }} - onBlur={validatePassword} + onBlur={() => { + setErrorPassword(validateNewPassword(password)); + + if (confirmPassword) { + setErrorPasswordMatch( + validatePasswordMatch(password, confirmPassword), + ); + } + }} value={password} isPassword error={errorPassword} @@ -173,7 +150,11 @@ export const ResetPassword = () => { setErrorPasswordMatch(""); setConfirmPassword(e.target.value); }} - onBlur={validatePasswordMatch} + onBlur={() => { + setErrorPasswordMatch( + validatePasswordMatch(password, confirmPassword), + ); + }} value={confirmPassword} isPassword error={errorPasswordMatch} diff --git a/src/pages/SetNewPassword.tsx b/src/pages/SetNewPassword.tsx new file mode 100644 index 0000000..ca5ac2e --- /dev/null +++ b/src/pages/SetNewPassword.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import { + Heading, + Input, + Button, + Notification, + Link, +} from "@stellar/design-system"; +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { USE_SSO, LOCAL_STORAGE_SESSION_TOKEN } from "constants/settings"; +import { singleUserStore } from "helpers/singleSingOn"; +import { validateNewPassword } from "helpers/validateNewPassword"; +import { validatePasswordMatch } from "helpers/validatePasswordMatch"; + +import { AppDispatch, resetStoreAction } from "store"; +import { setNewPasswordAction } from "store/ducks/forgotPassword"; +import { useRedux } from "hooks/useRedux"; + +export const SetNewPassword = () => { + const dispatch: AppDispatch = useDispatch(); + const navigate = useNavigate(); + + const { forgotPassword } = useRedux("forgotPassword"); + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmNewPassword, setConfirmNewPassword] = useState(""); + + const [errorPassword, setErrorPassword] = useState(""); + const [errorNewPassword, setErrorNewPassword] = useState(""); + const [errorPasswordMatch, setErrorPasswordMatch] = useState(""); + + useEffect(() => { + if (forgotPassword.status === "SUCCESS") { + setCurrentPassword(""); + setNewPassword(""); + setConfirmNewPassword(""); + } + }, [forgotPassword.status]); + + const handleSignOut = () => { + if (USE_SSO) { + // reset user store (from session storage) + singleUserStore(); + } + dispatch(resetStoreAction()); + localStorage.removeItem(LOCAL_STORAGE_SESSION_TOKEN); + }; + + const goToSignIn = ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + handleSignOut(); + navigate("/"); + }; + + const validatePassword = () => { + setErrorPassword(currentPassword ? "" : "Current password is required"); + }; + + const allInputsValid = () => { + if (errorPassword || errorNewPassword || errorPasswordMatch) { + return false; + } else if (currentPassword && newPassword && confirmNewPassword) { + return true; + } + + return false; + }; + + const handleResetCurrentPassword = ( + event: React.FormEvent, + ) => { + event.preventDefault(); + dispatch( + setNewPasswordAction({ + currentPassword, + newPassword, + }), + ); + }; + + return ( + <> +
+ {forgotPassword.status === "SUCCESS" && ( + + Password reset successfully. You can{" "} + sign in using your new password. + + )} + + {forgotPassword.errorString && ( + + {forgotPassword.errorString} + {forgotPassword.errorExtras ? ( +
    + {Object.entries(forgotPassword.errorExtras).map( + ([key, value]) => ( +
  • {`${key}: ${value}`}
  • + ), + )} +
+ ) : null} +
+ )} + +
+
+ + Reset password + + +
+ New password must be: +
    +
  • at least 8 characters long,
  • +
  • + a combination of uppercase letters, lowercase letters, + numbers, and symbols. +
  • +
+
+
+ + { + setErrorPassword(""); + setCurrentPassword(e.target.value); + }} + onBlur={validatePassword} + value={currentPassword} + isPassword + error={errorPassword} + /> + + { + setErrorNewPassword(""); + setNewPassword(e.target.value); + }} + onBlur={() => { + setErrorNewPassword(validateNewPassword(newPassword)); + + if (confirmNewPassword) { + setErrorPasswordMatch( + validatePasswordMatch(newPassword, confirmNewPassword), + ); + } + }} + value={newPassword} + isPassword + error={errorNewPassword} + /> + + { + setErrorPasswordMatch(""); + setConfirmNewPassword(e.target.value); + }} + onBlur={() => { + setErrorPasswordMatch( + validatePasswordMatch(newPassword, confirmNewPassword), + ); + }} + value={confirmNewPassword} + isPassword + error={errorPasswordMatch} + /> + + +
+
+ + ); +}; diff --git a/src/store/ducks/disbursementDetails.ts b/src/store/ducks/disbursementDetails.ts index 9315ba5..2514177 100644 --- a/src/store/ducks/disbursementDetails.ts +++ b/src/store/ducks/disbursementDetails.ts @@ -143,6 +143,7 @@ const initialState: DisbursementDetailsInitialState = { }, status: "DRAFT", fileName: undefined, + statusHistory: [], }, instructions: { csvName: undefined, diff --git a/src/store/ducks/disbursementDrafts.ts b/src/store/ducks/disbursementDrafts.ts index b996d55..c86d41e 100644 --- a/src/store/ducks/disbursementDrafts.ts +++ b/src/store/ducks/disbursementDrafts.ts @@ -99,7 +99,7 @@ export const saveDisbursementDraftAction = createAsyncThunk< }, ); -export const submitDisbursementDraftAction = createAsyncThunk< +export const submitDisbursementNewDraftAction = createAsyncThunk< string, { details: Disbursement; @@ -107,14 +107,20 @@ export const submitDisbursementDraftAction = createAsyncThunk< }, { rejectValue: DisbursementDraftRejectMessage; state: RootState } >( - "disbursementDrafts/submitDisbursementDraftAction", + "disbursementDrafts/submitDisbursementNewDraftAction", async ({ details, file }, { rejectWithValue, getState, dispatch }) => { const { token } = getState().userAccount; + const { id } = getState().disbursementDetails.details; const { newDraftId } = getState().disbursementDrafts; - let draftId; + + let draftId = id ?? newDraftId; try { - draftId = newDraftId ?? (await postDisbursement(token, details)).id; + if (!draftId) { + const newDisbursement = await postDisbursement(token, details); + draftId = newDisbursement.id; + } + await postDisbursementFile(token, draftId, file); await patchDisbursementStatus(token, draftId, "STARTED"); refreshSessionToken(dispatch); @@ -135,6 +141,88 @@ export const submitDisbursementDraftAction = createAsyncThunk< }, ); +export const saveNewCsvFileAction = createAsyncThunk< + boolean, + { + savedDraftId: string; + file: File; + }, + { rejectValue: DisbursementDraftRejectMessage; state: RootState } +>( + "disbursementDrafts/saveNewCsvFileAction", + async ({ savedDraftId, file }, { rejectWithValue, getState, dispatch }) => { + const { token } = getState().userAccount; + try { + await postDisbursementFile(token, savedDraftId, file); + refreshSessionToken(dispatch); + + return true; + } catch (error: unknown) { + const err = error as ApiError; + const errorString = handleApiErrorString(err); + endSessionIfTokenInvalid(errorString, dispatch); + + return rejectWithValue({ + errorString: `Error uploading new CSV file: ${errorString}`, + errorExtras: err?.extras, + }); + } + }, +); + +export const submitDisbursementSavedDraftAction = createAsyncThunk< + string, + { + // savedDraftId is always there for saved drafts, comes from the URL + savedDraftId?: string; + details: Disbursement; + file: File; + }, + { rejectValue: DisbursementDraftRejectMessage; state: RootState } +>( + "disbursementDrafts/submitDisbursementSavedDraftAction", + async ( + { details, file, savedDraftId }, + { rejectWithValue, getState, dispatch }, + ) => { + const { isApprovalRequired } = getState().organization.data; + const { token } = getState().userAccount; + const { id } = getState().disbursementDetails.details; + const { newDraftId } = getState().disbursementDrafts; + let draftId; + + try { + draftId = + savedDraftId ?? + // We might not need all of these ID checks, but I'll leave them here + // for now just in case + id ?? + newDraftId ?? + (await postDisbursement(token, details)).id; + + if (!isApprovalRequired) { + await postDisbursementFile(token, draftId, file); + } + + await patchDisbursementStatus(token, draftId, "STARTED"); + refreshSessionToken(dispatch); + + return draftId; + } catch (error: unknown) { + const err = error as ApiError; + const errorString = handleApiErrorString(err); + endSessionIfTokenInvalid(errorString, dispatch); + + return rejectWithValue({ + errorString: `Error submitting disbursement: ${errorString}`, + errorExtras: err?.extras, + // Need to save draft ID if it failed because of CSV upload + newDraftId: draftId, + }); + } + }, +); + const initialState: DisbursementDraftsInitialState = { items: [], status: undefined, @@ -143,6 +231,7 @@ const initialState: DisbursementDraftsInitialState = { errorString: undefined, errorExtras: undefined, actionType: undefined, + isCsvFileUpdated: undefined, }; const disbursementDraftsSlice = createSlice({ @@ -159,6 +248,9 @@ const disbursementDraftsSlice = createSlice({ setDraftIdAction: (state, action: PayloadAction) => { state.newDraftId = action.payload; }, + clearCsvUpdatedAction: (state) => { + state.isCsvFileUpdated = false; + }, }, extraReducers: (builder) => { // Get disbursement drafts @@ -201,28 +293,70 @@ const disbursementDraftsSlice = createSlice({ state.errorExtras = action.payload?.errorExtras; state.newDraftId = action.payload?.newDraftId; }); - // Submit disbursement + // Submit new disbursement builder.addCase( - submitDisbursementDraftAction.pending, + submitDisbursementNewDraftAction.pending, (state = initialState) => { state.status = "PENDING"; state.actionType = "submit"; }, ); builder.addCase( - submitDisbursementDraftAction.fulfilled, + submitDisbursementNewDraftAction.fulfilled, (state, action) => { state.newDraftId = action.payload; state.status = "SUCCESS"; state.errorString = undefined; }, ); - builder.addCase(submitDisbursementDraftAction.rejected, (state, action) => { + builder.addCase( + submitDisbursementNewDraftAction.rejected, + (state, action) => { + state.status = "ERROR"; + state.errorString = action.payload?.errorString; + state.errorExtras = action.payload?.errorExtras; + state.newDraftId = action.payload?.newDraftId; + }, + ); + // Submit new CSV file + builder.addCase(saveNewCsvFileAction.pending, (state = initialState) => { + state.status = "PENDING"; + }); + builder.addCase(saveNewCsvFileAction.fulfilled, (state, action) => { + state.isCsvFileUpdated = action.payload; + state.status = "SUCCESS"; + state.errorString = undefined; + }); + builder.addCase(saveNewCsvFileAction.rejected, (state, action) => { state.status = "ERROR"; state.errorString = action.payload?.errorString; state.errorExtras = action.payload?.errorExtras; - state.newDraftId = action.payload?.newDraftId; }); + // Submit saved disbursement + builder.addCase( + submitDisbursementSavedDraftAction.pending, + (state = initialState) => { + state.status = "PENDING"; + state.actionType = "submit"; + }, + ); + builder.addCase( + submitDisbursementSavedDraftAction.fulfilled, + (state, action) => { + state.newDraftId = action.payload; + state.status = "SUCCESS"; + state.errorString = undefined; + }, + ); + builder.addCase( + submitDisbursementSavedDraftAction.rejected, + (state, action) => { + state.status = "ERROR"; + state.errorString = action.payload?.errorString; + state.errorExtras = action.payload?.errorExtras; + state.newDraftId = action.payload?.newDraftId; + }, + ); }, }); @@ -233,4 +367,5 @@ export const { resetDisbursementDraftsAction, clearDisbursementDraftsErrorAction, setDraftIdAction, + clearCsvUpdatedAction, } = disbursementDraftsSlice.actions; diff --git a/src/store/ducks/forgotPassword.ts b/src/store/ducks/forgotPassword.ts index d573ea9..2e2299b 100644 --- a/src/store/ducks/forgotPassword.ts +++ b/src/store/ducks/forgotPassword.ts @@ -3,7 +3,9 @@ import { RootState } from "store"; import { ApiError, ForgotPasswordInitialState, RejectMessage } from "types"; import { postForgotPassword } from "api/postForgotPassword"; import { postResetPassword } from "api/postResetPassword"; +import { patchProfilePassword } from "api/patchProfilePassword"; import { handleApiErrorString } from "api/handleApiErrorString"; +import { endSessionIfTokenInvalid } from "helpers/endSessionIfTokenInvalid"; export const sendResetPasswordLinkAction = createAsyncThunk< string, @@ -45,10 +47,41 @@ export const resetPasswordAction = createAsyncThunk< }, ); +export const setNewPasswordAction = createAsyncThunk< + string, + { currentPassword: string; newPassword: string }, + { rejectValue: RejectMessage; state: RootState } +>( + "forgotPassword/setNewPasswordAction", + async ( + { currentPassword, newPassword }, + { rejectWithValue, getState, dispatch }, + ) => { + const { token } = getState().userAccount; + try { + const response = await patchProfilePassword(token, { + currentPassword, + newPassword, + }); + return response.message; + } catch (error: unknown) { + const err = error as ApiError; + const errorString = handleApiErrorString(error as ApiError); + endSessionIfTokenInvalid(errorString, dispatch); + + return rejectWithValue({ + errorString: `Error resetting password: ${errorString}`, + errorExtras: err?.extras, + }); + } + }, +); + const initialState: ForgotPasswordInitialState = { response: undefined, status: undefined, errorString: undefined, + errorExtras: undefined, }; const forgotPasswordSlice = createSlice({ @@ -87,6 +120,22 @@ const forgotPasswordSlice = createSlice({ state.status = "ERROR"; state.errorString = action.payload?.errorString; }); + //Set New Passsword + builder.addCase(setNewPasswordAction.pending, (state = initialState) => { + state.response = undefined; + state.status = "PENDING"; + state.errorString = undefined; + state.errorExtras = undefined; + }); + builder.addCase(setNewPasswordAction.fulfilled, (state, action) => { + state.response = action.payload; + state.status = "SUCCESS"; + }); + builder.addCase(setNewPasswordAction.rejected, (state, action) => { + state.status = "ERROR"; + state.errorString = action.payload?.errorString; + state.errorExtras = action.payload?.errorExtras; + }); }, }); diff --git a/src/store/ducks/organization.ts b/src/store/ducks/organization.ts index 6384433..5f58006 100644 --- a/src/store/ducks/organization.ts +++ b/src/store/ducks/organization.ts @@ -47,11 +47,19 @@ export const updateOrgInfoAction = createAsyncThunk< { rejectValue: RejectMessage; state: RootState } >( "organization/updateOrgInfoAction", - async ({ name, timezone, logo }, { rejectWithValue, getState, dispatch }) => { + async ( + { name, timezone, logo, isApprovalRequired }, + { rejectWithValue, getState, dispatch }, + ) => { const { token } = getState().userAccount; try { - const orgInfo = await patchOrgInfo(token, { name, timezone, logo }); + const orgInfo = await patchOrgInfo(token, { + name, + timezone, + logo, + isApprovalRequired, + }); return orgInfo.message; } catch (error: unknown) { const err = error as ApiError; @@ -70,21 +78,18 @@ export const getOrgLogoAction = createAsyncThunk< string, undefined, { rejectValue: RejectMessage; state: RootState } ->( - "organization/getOrgLogoAction", - async (_, { rejectWithValue, dispatch }) => { - try { - return await getOrgLogo(); - } catch (error: unknown) { - const errorString = handleApiErrorString(error as ApiError); - endSessionIfTokenInvalid(errorString, dispatch); +>("organization/getOrgLogoAction", async (_, { rejectWithValue, dispatch }) => { + try { + return await getOrgLogo(); + } catch (error: unknown) { + const errorString = handleApiErrorString(error as ApiError); + endSessionIfTokenInvalid(errorString, dispatch); - return rejectWithValue({ - errorString: `Error fetching organization logo: ${errorString}`, - }); - } - }, -); + return rejectWithValue({ + errorString: `Error fetching organization logo: ${errorString}`, + }); + } +}); export const getStellarAccountAction = createAsyncThunk< StellarAccountInfo, @@ -119,6 +124,7 @@ const initialState: OrganizationInitialState = { distributionAccountPublicKey: "", timezoneUtcOffset: "", assetBalances: undefined, + isApprovalRequired: undefined, }, updateMessage: undefined, status: undefined, @@ -152,6 +158,7 @@ const organizationSlice = createSlice({ distributionAccountPublicKey: action.payload.distribution_account_public_key, timezoneUtcOffset: action.payload.timezone_utc_offset, + isApprovalRequired: action.payload.is_approval_required, }; state.status = "SUCCESS"; }); diff --git a/src/store/ducks/profile.ts b/src/store/ducks/profile.ts index 72c7391..58baa3e 100644 --- a/src/store/ducks/profile.ts +++ b/src/store/ducks/profile.ts @@ -71,6 +71,7 @@ export const updateProfileInfoAction = createAsyncThunk< const initialState: ProfileInitialState = { data: { + id: "", firstName: "", lastName: "", email: "", @@ -104,6 +105,7 @@ const profileSlice = createSlice({ builder.addCase(getProfileInfoAction.fulfilled, (state, action) => { state.data = { ...state.data, + id: action.payload.id, firstName: action.payload.first_name, lastName: action.payload.last_name, email: action.payload.email, diff --git a/src/store/ducks/receiverDetails.ts b/src/store/ducks/receiverDetails.ts index edc3375..14313d9 100644 --- a/src/store/ducks/receiverDetails.ts +++ b/src/store/ducks/receiverDetails.ts @@ -1,6 +1,7 @@ import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; import { RootState } from "store"; import { getReceiverDetails } from "api/getReceiverDetails"; +import { patchReceiverInfo } from "api/patchReceiver"; import { handleApiErrorString } from "api/handleApiErrorString"; import { endSessionIfTokenInvalid } from "helpers/endSessionIfTokenInvalid"; import { refreshSessionToken } from "helpers/refreshSessionToken"; @@ -37,6 +38,37 @@ export const getReceiverDetailsAction = createAsyncThunk< }, ); +export const updateReceiverDetailsAction = createAsyncThunk< + string, + { receiverId: string; email: string; externalId: string }, + { rejectValue: RejectMessage; state: RootState } +>( + "receiverDetails/updateReceiverDetailsAction", + async ( + { receiverId, email, externalId }, + { rejectWithValue, getState, dispatch }, + ) => { + const { token } = getState().userAccount; + + try { + const profileInfo = await patchReceiverInfo(token, receiverId, { + email, + externalId, + }); + return profileInfo.message; + } catch (error: unknown) { + const err = error as ApiError; + const errorString = handleApiErrorString(err); + endSessionIfTokenInvalid(errorString, dispatch); + + return rejectWithValue({ + errorString: `Error updating profile info: ${errorString}`, + errorExtras: err?.extras, + }); + } + }, +); + const initialState: ReceiverDetailsInitialState = { id: "", phoneNumber: "", @@ -64,7 +96,14 @@ const initialState: ReceiverDetailsInitialState = { assetCode: "", }, ], + verifications: [ + { + verificationField: "", + value: "", + }, + ], status: undefined, + updateStatus: undefined, errorString: undefined, }; @@ -89,6 +128,8 @@ const receiverDetailsSlice = createSlice({ state.totalReceived = action.payload.totalReceived; state.stats = action.payload.stats; state.wallets = action.payload.wallets; + state.verifications = action.payload.verifications; + state.email = action.payload.email; state.orgId = action.payload.orgId; state.status = "SUCCESS"; state.errorString = undefined; @@ -97,6 +138,21 @@ const receiverDetailsSlice = createSlice({ state.status = "ERROR"; state.errorString = action.payload?.errorString; }); + //updateReceiverDetailsAction + builder.addCase( + updateReceiverDetailsAction.pending, + (state = initialState) => { + state.updateStatus = "PENDING"; + }, + ); + builder.addCase(updateReceiverDetailsAction.fulfilled, (state) => { + state.updateStatus = "SUCCESS"; + state.errorString = undefined; + }); + builder.addCase(updateReceiverDetailsAction.rejected, (state, action) => { + state.updateStatus = "ERROR"; + state.errorString = action.payload?.errorString; + }); }, }); @@ -108,6 +164,7 @@ export const { resetReceiverDetailsAction } = receiverDetailsSlice.actions; const formatReceiver = (receiver: ApiReceiver): ReceiverDetails => ({ id: receiver.id, phoneNumber: receiver.phone_number, + email: receiver.email, orgId: receiver.external_id, // TODO: how to handle multiple assetCode: receiver.received_amounts[0].asset_code, @@ -132,4 +189,8 @@ const formatReceiver = (receiver: ApiReceiver): ReceiverDetails => ({ // TODO: withdrawn amount withdrawnAmount: "", })), + verifications: receiver.verifications.map((v) => ({ + verificationField: v.VerificationField, + value: v.HashedValue, + })), }); diff --git a/src/types/index.ts b/src/types/index.ts index affd091..6e508e8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -86,6 +86,7 @@ export type DisbursementDraftsInitialState = { errorString?: string; errorExtras?: AnyObject; actionType?: DisbursementDraftAction; + isCsvFileUpdated?: boolean; }; export type DisbursementsInitialState = { @@ -107,6 +108,7 @@ export type ForgotPasswordInitialState = { response?: string; status: ActionStatus | undefined; errorString?: string; + errorExtras?: AnyObject; }; export type PaymentsInitialState = { @@ -153,7 +155,9 @@ export type ReceiverDetailsInitialState = { paymentsRemainingCount: number; }; wallets: ReceiverWallet[]; + verifications: ReceiverVerification[]; status: ActionStatus | undefined; + updateStatus: ActionStatus | undefined; errorString?: string; }; @@ -172,6 +176,7 @@ export type OrganizationInitialState = { distributionAccountPublicKey: string; timezoneUtcOffset: string; assetBalances?: StellarAccountInfo[]; + isApprovalRequired: boolean | undefined; }; updateMessage?: string; status: ActionStatus | undefined; @@ -355,6 +360,11 @@ export type Disbursement = { }; status: DisbursementStatus; fileName?: string; + statusHistory: { + status: DisbursementStatus; + timestamp: string; + userId: string | null; + }[]; }; export type DisbursementsSearchParams = CommonFilters & @@ -472,6 +482,11 @@ export type ReceiverWallet = { assetCode: string; }; +export type ReceiverVerification = { + verificationField: string; + value: string; +}; + export type ReceiverWalletBalance = { assetCode: string; assetIssuer: string; @@ -506,6 +521,12 @@ export type ReceiverDetails = { paymentsRemainingCount: number; }; wallets: ReceiverWallet[]; + verifications: ReceiverVerification[]; +}; + +export type ReceiverEditFields = { + email: string; + externalId: string; }; // ============================================================================= @@ -529,6 +550,7 @@ export type HomeStatistics = { // Profile // ============================================================================= export type AccountProfile = { + id: string; firstName: string; lastName: string; email: string; @@ -542,6 +564,7 @@ export type OrgUpdateInfo = { name?: string; timezone?: string; logo?: File; + isApprovalRequired?: boolean; }; // ============================================================================= @@ -792,6 +815,11 @@ export type ApiReceiverWallet = { }[]; }; +export type ApiReceiverVerification = { + VerificationField: string; + HashedValue: string; +}; + export type ApiReceiver = { created_at: string; id: string; @@ -809,6 +837,7 @@ export type ApiReceiver = { }[]; registered_wallets: string; wallets: ApiReceiverWallet[]; + verifications: ApiReceiverVerification[]; }; export type ApiReceivers = { @@ -817,6 +846,7 @@ export type ApiReceivers = { }; export type ApiProfileInfo = { + id: string; first_name: string; last_name: string; email: string; @@ -829,6 +859,7 @@ export type ApiOrgInfo = { logo_url: string; distribution_account_public_key: string; timezone_utc_offset: string; + is_approval_required: boolean; }; export type ApiStellarAccountBalance = {