diff --git a/.vscode/settings.json b/.vscode/settings.json index cd20aa6..2b9b96b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,7 @@ "Lngs", "Lyqydate", "Mutex", + "Notif", "Popconfirm", "Precache", "Sider", diff --git a/public/locales/en.json b/public/locales/en.json index a7e1db6..c26c176 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -763,7 +763,7 @@ "labelFrom": "From wallet", "labelTo": "To address/name", - "labelValue": "Amount", + "labelAmount": "Amount", "labelMetadata": "Metadata", "placeholderMetadata": "Optional metadata", @@ -775,9 +775,31 @@ "errorAmountTooHigh": "Insufficient funds in wallet.", "errorMetadataTooLong": "Metadata must be less than 256 characters.", - "errorMetadataInvalid": "Metadata contains invalid characters." + "errorMetadataInvalid": "Metadata contains invalid characters.", - // TODO: modals/warnings for confirmation of sending over 50% of balance, - // whole balance, and form validation messages + "errorWalletGone": "That wallet no longer exists.", + "errorWalletDecrypt": "Your wallet could not be decrypted.", + + "errorParameterTo": "Invalid recipient.", + "errorParameterAmount": "Invalid amount.", + "errorParameterMetadata": "Invalid metadata.", + "errorInsufficientFunds": "Insufficient funds in wallet.", + "errorNameNotFound": "The recipient name could not be found.", + + "errorUnknown": "Unknown error sending transaction. See console for details.", + + "payLargeConfirmHalf": "Are you sure you want to send <1 />? This is over half your balance!", + "payLargeConfirmAll": "Are you sure you want to send <1 />? This is your entire balance!", + + "successNotificationTitle": "Transaction successful", + "successNotificationContent": "You sent <1 /> from <3 /> to <5 />.", + "successNotificationButton": "View transaction" + }, + + "authFailed": { + "title": "Auth failed", + "message": "You do not own this address.", + "messageLocked": "This address was locked.", + "alert": "Message from the sync node:" } } diff --git a/src/components/addresses/picker/AddressPicker.tsx b/src/components/addresses/picker/AddressPicker.tsx index 413c0aa..62feff0 100644 --- a/src/components/addresses/picker/AddressPicker.tsx +++ b/src/components/addresses/picker/AddressPicker.tsx @@ -107,6 +107,7 @@ : options; // Fetch an address or name hint if possible + // TODO: Make sure these auto-refresh when the balances change const pickerHints = usePickerHints(nameHint, cleanValue, hasExactName); const classes = classNames("address-picker", className, { diff --git a/src/krist/api/AuthFailed.tsx b/src/krist/api/AuthFailed.tsx new file mode 100644 index 0000000..b7db12c --- /dev/null +++ b/src/krist/api/AuthFailed.tsx @@ -0,0 +1,99 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { useState, useEffect, ReactElement } from "react"; +import { Modal, Spin, Alert } from "antd"; +import { ModalStaticFunctions } from "antd/lib/modal/confirm"; + +import { useTranslation } from "react-i18next"; + +import { Wallet, decryptWallet, useMasterPasswordOnly } from "@wallets"; + +import * as api from "./"; + +interface AuthFailedModalHookResponse { + authFailedModal: Omit; + authFailedContextHolder: ReactElement; + showAuthFailed: (wallet: Wallet) => void; +} + +/** + * Hook that returns a Modal instance and a contextHolder for it. When the + * modal is shown, it performs an alert lookup for the given private key, + * showing the alert if possible, and otherwise a generic 'auth failed' + * message. + */ +export function useAuthFailedModal(): AuthFailedModalHookResponse { + const { t } = useTranslation(); + const [modal, contextHolder] = Modal.useModal(); + + // Create the auth failed modal and show it + function showAuthFailed(wallet: Wallet) { + modal.error({ + title: t("authFailed.title"), + content: + }); + } + + return { + authFailedModal: modal, + authFailedContextHolder: contextHolder, + showAuthFailed + }; +} + +interface AlertAPIResponse { + alert: string; +} + +function ModalContents({ wallet }: { wallet: Wallet }): JSX.Element { + const { t } = useTranslation(); + + // The alert from the sync node + const [alert, setAlert] = useState(); + const [loading, setLoading] = useState(true); + + // Needed to decrypt the wallet, as the privatekey is required to get alert + const masterPassword = useMasterPasswordOnly(); + + // Fetch the alert from the sync node (this will usually determine if the + // address was locked or it's just a collision) + useEffect(() => { + if (!masterPassword) return; + + // Errors are generally ignored here, this whole dialog is a very minor + // edge case that only a few will see. + (async () => { + // Decrypt the wallet + const decrypted = await decryptWallet(masterPassword, wallet); + if (!decrypted) return; // This should never happen + const { privatekey } = decrypted; + + // Perform the fetch + api.post("/addresses/alert", { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ privatekey }) + }) + .then(res => setAlert(res.alert)) + .catch(console.error) + .finally(() => setLoading(false)); + })().catch(console.error); + }, [wallet, masterPassword]); + + return + {alert + // If there is a known alert from the server, this address is locked, show + // the alert: + ? <> +

{t("authFailed.messageLocked")}

+ + + + // Otherwise, show a generic "You do not own this address." message. + : t("authFailed.message")} +
; +} diff --git a/src/krist/api/transactions.ts b/src/krist/api/transactions.ts new file mode 100644 index 0000000..3ca8e0f --- /dev/null +++ b/src/krist/api/transactions.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { TranslatedError } from "@utils/i18n"; + +import { KristTransaction } from "./types"; +import * as api from "."; + +import { Wallet, decryptWallet } from "@wallets"; + +interface MakeTransactionResponse { + transaction: KristTransaction; +} + +export async function makeTransaction( + masterPassword: string, + from: Wallet, + to: string, + amount: number, + metadata?: string +): Promise { + // Attempt to decrypt the wallet to get the privatekey + const decrypted = await decryptWallet(masterPassword, from); + if (!decrypted) + throw new TranslatedError("sendTransaction.errorWalletDecrypt"); + const { privatekey } = decrypted; + + const { transaction } = await api.post( + "/transactions", + { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + privatekey, to, amount, + metadata: metadata || undefined // Clean up empty strings + }) + } + ); + + return transaction; +} diff --git a/src/pages/transactions/send/AmountInput.tsx b/src/pages/transactions/send/AmountInput.tsx index 9ad1f26..6baebcc 100644 --- a/src/pages/transactions/send/AmountInput.tsx +++ b/src/pages/transactions/send/AmountInput.tsx @@ -12,13 +12,13 @@ interface Props { from: string; - setValue: (value: number) => void; + setAmount: (amount: number) => void; tabIndex?: number; } export function AmountInput({ from, - setValue, + setAmount, tabIndex, ...props }: Props): JSX.Element { @@ -32,11 +32,11 @@ function onClickMax() { const currentWallet = walletAddressMap[from]; - setValue(currentWallet?.balance || 0); + setAmount(currentWallet?.balance || 0); } return @@ -49,9 +49,9 @@ )} - {/* Value/amount number input */} + {/* Amount number input */} form.setFieldsValue({ value })} + setAmount={amount => form.setFieldsValue({ amount })} tabIndex={3} /> @@ -132,37 +138,168 @@ ; } +interface TransactionFormHookProps { + onError?: (err: Error) => void; + onSuccess?: (transaction: KristTransaction) => void; +} + interface TransactionFormHookResponse { form: FormInstance; triggerSubmit: () => Promise; + triggerReset: () => void; isSubmitting: boolean; txForm: JSX.Element; } -export function useTransactionForm(): TransactionFormHookResponse { +export function useTransactionForm({ + onError, + onSuccess +}: TransactionFormHookProps = {}): TransactionFormHookResponse { + const { t } = useTranslation(); + const [form] = Form.useForm(); const [isSubmitting, setIsSubmitting] = useState(false); + // Used to check for warning on large transactions + const { walletAddressMap } = useWallets(); + // Used to decrypt the wallet to make the transaction + const masterPassword = useMasterPasswordOnly(); + + // Confirmation modal used for when the transaction amount is very large. + // This is created here to provide a translation context for the modal. + const [confirmModal, contextHolder] = Modal.useModal(); + // Modal used when auth fails + const { showAuthFailed, authFailedContextHolder } = useAuthFailedModal(); + + // Called when the modal is closed + function onReset() { + form.resetFields(); + setIsSubmitting(false); + } + + // Take the form values and known wallet and submit the transaction + async function submitTransaction( + { to, amount, metadata }: FormValues, + wallet: Wallet + ): Promise { + if (!masterPassword) + throw new TranslatedError("sendTransaction.errorWalletDecrypt"); + + // API errors will be bubbled up to the caller + const tx = await makeTransaction( + masterPassword, + wallet, + to, + amount, + metadata + ); + + onSuccess?.(tx); + } + + // Convert API errors to friendlier errors + function handleError(err: Error, from?: Wallet): void { + // Construct a TranslatedError pre-keyed to sendTransaction + const tErr = (key: string) => new TranslatedError("sendTransaction." + key); + + switch (err.message) { + case "missing_parameter": + case "invalid_parameter": + switch ((err as APIError).parameter) { + case "to": + return onError?.(tErr("errorParameterTo")); + case "amount": + return onError?.(tErr("errorParameterAmount")); + case "metadata": + return onError?.(tErr("errorParameterMetadata")); + } + break; + case "insufficient_funds": + return onError?.(tErr("errorInsufficientFunds")); + case "name_not_found": + return onError?.(tErr("errorNameNotFound")); + case "auth_failed": + return showAuthFailed(from!); + } + + // Pass through any other unknown errors + onError?.(err); + } + async function onSubmit() { setIsSubmitting(true); - try { - const values = await form.validateFields(); - console.log(values); - } finally { - setTimeout(() => setIsSubmitting(false), 1000); + // Get the form values + const [err, values] = await awaitTo(form.validateFields()); + if (err || !values) { + // Validation errors are handled by the form + setIsSubmitting(false); + return; + } + + // Find the wallet we're trying to pay from, and verify it actually exists + // and has a balance (shouldn't happen) + const currentWallet = walletAddressMap[values.from]; + if (!currentWallet) + throw new TranslatedError("sendTransaction.errorWalletGone"); + if (!currentWallet.balance) + throw new TranslatedError("sendTransaction.errorAmountTooHigh"); + + // If the transaction is large (over half the balance), prompt for + // confirmation before sending + const { amount } = values; + const isLarge = amount >= currentWallet.balance / 2; + if (isLarge) { + // It's large, prompt for confirmation + confirmModal.confirm({ + title: t("sendTransaction.modalTitle"), + content: ( + // Show the appropriate message, if this is just over half the + // balance, or if it is the entire balance. + = currentWallet.balance + ? "sendTransaction.payLargeConfirmAll" + : "sendTransaction.payLargeConfirmHalf"} + > + Are you sure you want to send ? + This is over half your balance! + + ), + + // Transaction looks OK, submit it + okText: t("sendTransaction.buttonSubmit"), + onOk: () => submitTransaction(values, currentWallet) + .catch(err => handleError(err, currentWallet)) + .finally(() => setIsSubmitting(false)), + + cancelText: t("dialog.cancel"), + onCancel: () => setIsSubmitting(false) + }); + } else { + // Transaction looks OK, submit it + submitTransaction(values, currentWallet) + .catch(err => handleError(err, currentWallet)) + .finally(() => setIsSubmitting(false)); } } // Create the transaction form instance here to be rendered by the caller - const txForm = ; + const txForm = <> + + + {/* Give the modals somewhere to find the context from. */} + {contextHolder} + {authFailedContextHolder} + ; return { form, triggerSubmit: onSubmit, + triggerReset: onReset, isSubmitting, txForm }; diff --git a/src/pages/transactions/send/SendTransactionModal.tsx b/src/pages/transactions/send/SendTransactionModal.tsx index 0dfda46..7abc748 100644 --- a/src/pages/transactions/send/SendTransactionModal.tsx +++ b/src/pages/transactions/send/SendTransactionModal.tsx @@ -1,14 +1,21 @@ // Copyright (c) 2020-2021 Drew Lemmy // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -import { Dispatch, SetStateAction } from "react"; -import { Modal } from "antd"; +import React, { Dispatch, SetStateAction } from "react"; +import { Modal, Button, notification } from "antd"; -import { useTranslation } from "react-i18next"; +import { useTranslation, Trans } from "react-i18next"; +import { translateError } from "@utils/i18n"; + +import { Link } from "react-router-dom"; import { useWallets } from "@wallets"; import { NoWalletsModal } from "@comp/results/NoWalletsResult"; +import { KristTransaction } from "@api/types"; +import { KristValue } from "@comp/krist/KristValue"; +import { ContextualAddress } from "@comp/addresses/ContextualAddress"; + import { useTransactionForm } from "./SendTransactionForm"; interface Props { @@ -21,40 +28,101 @@ setVisible }: Props): JSX.Element { const { t } = useTranslation(); - const { form, isSubmitting, triggerSubmit, txForm } = useTransactionForm(); + + // Grab a context to display a button in the success notification + const [notif, contextHolder] = notification.useNotification(); + + // Create the transaction form + const { isSubmitting, triggerSubmit, triggerReset, txForm } = useTransactionForm({ + // Display a success notification when the transaction is made + onSuccess(tx: KristTransaction) { + notif.success({ + message: t("sendTransaction.successNotificationTitle"), + description: , + btn: + }); + + // Close when done + closeModal(); + }, + + // Display errors as notifications in the modal + onError: err => notification.error({ + message: t("error"), + description: translateError(t, err, "sendTransaction.errorUnknown") + }) + }); // Don't open the modal if there are no wallets. const { addressList } = useWallets(); const hasWallets = addressList?.length > 0; function closeModal() { - form.resetFields(); + triggerReset(); setVisible(false); } - return hasWallets - ? ( - + {hasWallets + ? ( + - {txForm} - - ) - : ( - - ); + onCancel={closeModal} + cancelText={t("dialog.cancel")} + destroyOnClose + > + {txForm} + + ) + : ( + + ) + } + + {/* Context for success notification */} + {contextHolder} + ; +} + +function NotifSuccessContents({ tx }: { tx: KristTransaction }): JSX.Element { + const { t } = useTranslation(); + + return + You sent + + from + + to + + ; +} + +function NotifSuccessButton({ tx }: { tx: KristTransaction }): JSX.Element { + const { t } = useTranslation(); + + return + + ; }