diff --git a/package.json b/package.json index 58c2aed..a10f796 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "i18next-http-backend": "^1.1.1", "lodash-es": "^4.17.21", "lru-cache": "^6.0.0", + "rc-menu": "^8.10.6", "react": "^17.0.1", "react-chartjs-2": "^2.11.1", "react-dom": "^17.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bb9287..f6c9ab6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,7 @@ i18next-http-backend: 1.1.1 lodash-es: 4.17.21 lru-cache: 6.0.0 + rc-menu: 8.10.6_react-dom@17.0.1+react@17.0.1 react: 17.0.1 react-chartjs-2: 2.11.1_6c446a34f83b2a92e3214f8b711c141a react-dom: 17.0.1_react@17.0.1 @@ -56,7 +57,7 @@ '@types/semver': 7.3.4 '@types/uuid': 8.3.0 '@types/webpack-env': 1.16.0 - '@typescript-eslint/eslint-plugin': 4.15.3-alpha.17_0c05b3d2e4bc9c73120c3f1c1aed4425 + '@typescript-eslint/eslint-plugin': 4.15.3-alpha.17_bcfedfa8b2673ad3028fc37ce1448d88 '@typescript-eslint/parser': 4.15.3-alpha.17_eslint@7.21.0+typescript@4.1.5 antd-dayjs-webpack-plugin: 1.0.6_dayjs@1.10.4 babel-plugin-lodash: 3.3.4 @@ -2275,7 +2276,7 @@ '@types/yargs-parser': 20.2.0 resolution: integrity: sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ== - /@typescript-eslint/eslint-plugin/4.15.3-alpha.17_0c05b3d2e4bc9c73120c3f1c1aed4425: + /@typescript-eslint/eslint-plugin/4.15.3-alpha.17_bcfedfa8b2673ad3028fc37ce1448d88: dependencies: '@typescript-eslint/experimental-utils': 4.15.3-alpha.17_eslint@7.21.0+typescript@4.1.5 '@typescript-eslint/parser': 4.15.3-alpha.17_eslint@7.21.0+typescript@4.1.5 @@ -13858,6 +13859,7 @@ i18next-http-backend: ^1.1.1 lodash-es: ^4.17.21 lru-cache: ^6.0.0 + rc-menu: ^8.10.6 react: ^17.0.1 react-chartjs-2: ^2.11.1 react-dom: ^17.0.1 diff --git a/public/locales/en.json b/public/locales/en.json index e453e16..5b55926 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -146,11 +146,45 @@ "walletCount_plural": "{{count, number}} wallets", "actionsEditTooltip": "Edit wallet", + "actionsWalletInfo": "Wallet info", "actionsDelete": "Delete wallet", "actionsDeleteConfirm": "Are you sure you want to delete this wallet?", + "actionsDeleteConfirmDescription": "If you haven't backed it up or saved its password, it will be lost forever!", "tagDontSave": "Temp", - "tagDontSaveTooltip": "Temporary wallet" + "tagDontSaveTooltip": "Temporary wallet", + + "info": { + "title": "Wallet info - {{address}}", + + "titleBasicInfo": "Basic info", + "id": "ID", + "label": "Label", + "category": "Category", + "username": "Username", + "password": "Password", + "privatekey": "Private key", + "format": "Format", + + "titleSyncedInfo": "Synced info", + "address": "Address", + "balance": "Balance", + "names": "Name count", + "firstSeen": "First seen", + "existsOnNetwork": "Exists on network", + "lastSynced": "Last synced", + + "titleAdvancedInfo": "Advanced info", + "encPassword": "Encrypted password", + "encPrivatekey": "Encrypted private key", + "saved": "Saved", + + "revealLink": "Reveal", + "hideLink": "Hide", + + "true": "True", + "false": "False" + } }, "myTransactions": { @@ -686,5 +720,7 @@ } }, - "walletLimitMessage": "You have more wallets stored than KristWeb supports. This was either caused by a bug, or you bypassed it intentionally. Expect issues with syncing." + "walletLimitMessage": "You have more wallets stored than KristWeb supports. This was either caused by a bug, or you bypassed it intentionally. Expect issues with syncing.", + + "optionalFieldUnset": "(unset)" } diff --git a/src/components/DateTime.tsx b/src/components/DateTime.tsx index d5db61d..48e0ab8 100644 --- a/src/components/DateTime.tsx +++ b/src/components/DateTime.tsx @@ -18,12 +18,21 @@ small?: boolean; secondary?: boolean; neverRelative?: boolean; + tooltip?: React.ReactNode | false; } type Props = React.HTMLProps & OwnProps; const RELATIVE_DATE_THRESHOLD = 1000 * 60 * 60 * 24 * 7; -export function DateTime({ date, timeAgo, small, secondary, neverRelative, ...props }: Props): JSX.Element | null { +export function DateTime({ + date, + timeAgo, + small, + secondary, + neverRelative, + tooltip, + ...props +}: Props): JSX.Element | null { const showRelativeDates = useBooleanSetting("showRelativeDates"); if (!date) return null; @@ -38,11 +47,19 @@ "date-time-secondary": secondary }); - return + const contents = ( {isTimeAgo ? : dayjs(realDate).format("YYYY/MM/DD HH:mm:ss")} - ; + ); + + return tooltip || tooltip === undefined + ? ( + + {contents} + + ) + : contents; } diff --git a/src/components/OptionalField.less b/src/components/OptionalField.less new file mode 100644 index 0000000..ef2a930 --- /dev/null +++ b/src/components/OptionalField.less @@ -0,0 +1,11 @@ +// 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 (reference) "../App.less"; + +.optional-field { + &.optional-field-unset { + color: @text-color-secondary; + font-style: italic; + } +} diff --git a/src/components/OptionalField.tsx b/src/components/OptionalField.tsx new file mode 100644 index 0000000..c8af9fb --- /dev/null +++ b/src/components/OptionalField.tsx @@ -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 React from "react"; +import classNames from "classnames"; +import { Typography } from "antd"; +import { CopyConfig } from "./types"; + +import { useTranslation } from "react-i18next"; + +import "./OptionalField.less"; + +const { Text } = Typography; + +interface Props { + value?: React.ReactNode | null | undefined; + copyable?: boolean | CopyConfig; + unsetKey?: string; + className?: string +} + +export function OptionalField({ + value, + copyable, + unsetKey, + className +}: Props): JSX.Element { + const { t } = useTranslation(); + + const unset = value === undefined || value === null; + const classes = classNames("optional-field", className, { + "optional-field-unset": unset + }); + + return + {unset + ? t(unsetKey || "optionalFieldUnset") + : {value}} + ; +} diff --git a/src/components/addresses/ContextualAddress.less b/src/components/addresses/ContextualAddress.less index d6defe9..256c71a 100644 --- a/src/components/addresses/ContextualAddress.less +++ b/src/components/addresses/ContextualAddress.less @@ -19,7 +19,7 @@ opacity: 0.8; } - &.contextual-address-non-existent { + &.contextual-address-non-existent a { color: @text-color-secondary; cursor: not-allowed; diff --git a/src/components/auth/AuthorisedAction.tsx b/src/components/auth/AuthorisedAction.tsx index 7cb536e..85db53f 100644 --- a/src/components/auth/AuthorisedAction.tsx +++ b/src/components/auth/AuthorisedAction.tsx @@ -19,6 +19,7 @@ encrypt?: boolean; onAuthed?: () => void; popoverPlacement?: TooltipPlacement; + children: React.ReactNode; } export const AuthorisedAction: FC = ({ encrypt, onAuthed, popoverPlacement, children }) => { @@ -29,29 +30,32 @@ const [clicked, setClicked] = useState(false); const [modalVisible, setModalVisible] = useState(false); + // This is used to pass the 'onClick' prop down to the child. The child MUST + // support the onClick prop. + // NOTE: If the child is a custom component, make sure it passes `...props` + // down to its child. + // TODO: Support multiple children? + const child = React.Children.only(children) as React.ReactElement; + if (isAuthed) { // The user is authed with their master password, just perform the action // directly: - return { + return React.cloneElement(child, { onClick: (e: MouseEvent) => { e.preventDefault(); debug("authorised action occurred: was already authed"); if (onAuthed) onAuthed(); - }}> - {children} - ; + }}); } else if (!hasMasterPassword) { // The user does not yet have a master password, prompt them to create one: return <> - { + {React.cloneElement(child, { onClick: (e: MouseEvent) => { e.preventDefault(); debug("authorised action postponed: no master password set"); if (!clicked) setClicked(true); setModalVisible(true); - }}> - {children} - + }})} {clicked && void; + icon?: React.ReactNode; + tooltips?: boolean | React.ReactNode; +} diff --git a/src/layout/nav/AppHeader.tsx b/src/layout/nav/AppHeader.tsx index f787e07..624a678 100644 --- a/src/layout/nav/AppHeader.tsx +++ b/src/layout/nav/AppHeader.tsx @@ -6,7 +6,6 @@ import { SendOutlined, DownloadOutlined, MenuOutlined, SettingOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; import { Brand } from "./Brand"; import { Search } from "./Search"; diff --git a/src/pages/wallets/WalletActions.tsx b/src/pages/wallets/WalletActions.tsx new file mode 100644 index 0000000..f72983f --- /dev/null +++ b/src/pages/wallets/WalletActions.tsx @@ -0,0 +1,93 @@ +// 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 React, { useState } from "react"; +import { Modal, Tooltip, Dropdown, Menu } from "antd"; +import { MenuClickEventHandler } from "rc-menu/lib/interface"; +import { + EditOutlined, DeleteOutlined, InfoCircleOutlined, ExclamationCircleOutlined +} from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +import { WalletEditButton } from "./WalletEditButton"; +import { AddWalletModal } from "./AddWalletModal"; +import { WalletInfoModal } from "./info/WalletInfoModal"; + +import { Wallet, deleteWallet } from "@wallets"; + +export function WalletActions({ wallet }: { wallet: Wallet }): JSX.Element { + const { t } = useTranslation(); + const [editWalletVisible, setEditWalletVisible] = useState(false); + const [walletInfoVisible, setWalletInfoVisible] = useState(false); + + function showWalletDeleteConfirm(): void { + Modal.confirm({ + icon: , + + title: t("myWallets.actionsDeleteConfirm"), + content: t("myWallets.actionsDeleteConfirmDescription"), + + onOk: () => deleteWallet(wallet), + okText: t("dialog.yes"), + okType: "danger", + cancelText: t("dialog.no") + }); + } + + const onMenuClick: MenuClickEventHandler = (e) => { + switch (e.key) { + // "Wallet info" button + case "1": + setWalletInfoVisible(true); + break; + + // "Delete wallet" button + case "2": + showWalletDeleteConfirm(); + break; + } + }; + + return <> + [ + + + {React.cloneElement(leftButton as React.ReactElement, { + className: "ant-btn-left", // force the border-radius + disabled: wallet.dontSave + })} + + , + rightButton + ]} + + trigger={["click"]} + + overlay={( + + {/* Wallet info button */} + + {t("myWallets.actionsWalletInfo")} + + + + + {/* Delete button */} + + {t("myWallets.actionsDelete")} + + + )}> + + {/* Edit button */} + + + + + + ; +} diff --git a/src/pages/wallets/WalletsTable.tsx b/src/pages/wallets/WalletsTable.tsx index 8afd47b..247e221 100644 --- a/src/pages/wallets/WalletsTable.tsx +++ b/src/pages/wallets/WalletsTable.tsx @@ -1,71 +1,20 @@ // 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 React, { useState } from "react"; -import { Table, Tooltip, Dropdown, Tag, Menu, Popconfirm } from "antd"; -import { EditOutlined, DeleteOutlined } from "@ant-design/icons"; +import React from "react"; +import { Table, Tooltip, Tag } from "antd"; import { useTranslation } from "react-i18next"; import { ContextualAddress } from "@comp/addresses/ContextualAddress"; import { KristValue } from "@comp/krist/KristValue"; import { DateTime } from "@comp/DateTime"; -import { WalletEditButton } from "./WalletEditButton"; -import { AddWalletModal } from "./AddWalletModal"; -import { Wallet, deleteWallet, useWallets } from "@wallets"; +import { useWallets } from "@wallets"; +import { WalletActions } from "./WalletActions"; import { keyedNullSort, localeSort } from "@utils"; -function WalletActions({ wallet }: { wallet: Wallet }): JSX.Element { - const { t } = useTranslation(); - const [editWalletVisible, setEditWalletVisible] = useState(false); - - function onDeleteWallet() { - deleteWallet(wallet); - } - - return <> - [ - - - {React.cloneElement(leftButton as React.ReactElement, { - className: "ant-btn-left", // force the border-radius - disabled: wallet.dontSave - })} - - , - rightButton - ]} - - overlay={( - - {/* Delete button */} - - - {t("myWallets.actionsDelete")} - - - - )}> - - {/* Edit button */} - - - - - ; -} - export function WalletsTable(): JSX.Element { const { t } = useTranslation(); const { wallets } = useWallets(); diff --git a/src/pages/wallets/info/BooleanText.tsx b/src/pages/wallets/info/BooleanText.tsx new file mode 100644 index 0000000..15e6001 --- /dev/null +++ b/src/pages/wallets/info/BooleanText.tsx @@ -0,0 +1,17 @@ +// 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 React from "react"; +import { Typography } from "antd"; + +import { useTranslation } from "react-i18next"; + +const { Text } = Typography; + +export function BooleanText({ value }: { value?: boolean }): JSX.Element { + const { t } = useTranslation(); + + return value + ? {t("myWallets.info.true")} + : {t("myWallets.info.false")}; +} diff --git a/src/pages/wallets/info/DecryptReveal.tsx b/src/pages/wallets/info/DecryptReveal.tsx new file mode 100644 index 0000000..61bf343 --- /dev/null +++ b/src/pages/wallets/info/DecryptReveal.tsx @@ -0,0 +1,89 @@ +// 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 React, { useState, useEffect } from "react"; +import classNames from "classnames"; +import { Typography } from "antd"; +import { CopyConfig } from "@comp/types"; + +import { useTranslation } from "react-i18next"; + +import { useSelector, shallowEqual } from "react-redux"; +import { RootState } from "@store"; + +import { aesGcmDecrypt } from "@utils/crypto"; +import { AuthorisedAction } from "@comp/auth/AuthorisedAction"; + +const { Text, Link } = Typography; + +interface Props { + value?: string; + encrypted?: boolean; + copyable?: boolean | CopyConfig; + className?: string; +} + +/** Hides a piece of data until a 'Reveal' link is clicked. If necessary, the + * user will be prompted for their master password to decrypt the data. */ +export function DecryptReveal({ + value, + encrypted, + copyable, + className +}: Props): JSX.Element { + const { t } = useTranslation(); + + const { isAuthed, masterPassword } + = useSelector((s: RootState) => s.masterPassword, shallowEqual); + + const [revealed, setRevealed] = useState(false); + const [decrypted, setDecrypted] = useState(); + + const reveal = () => setRevealed(true); + + useEffect(() => { + if (!isAuthed || !masterPassword || !value || !revealed || decrypted) + return; + + (async () => { + const dec = await aesGcmDecrypt(value, masterPassword); + setDecrypted(dec); + })().catch(console.error); + }, [isAuthed, masterPassword, value, revealed, decrypted]); + + function RevealedContents(): JSX.Element { + return <> + + {encrypted ? decrypted : value} + + + {/* Hide link */} + setRevealed(false)} + style={{ display: "inline-block", marginLeft: 8 }} + > + {t("myWallets.info.hideLink")} + + ; + } + + const canReveal = !encrypted || isAuthed; + const revealLink = ( + + {t("myWallets.info.revealLink")} + + ); + + const classes = classNames("decrypt-reveal", className, { + "decrypt-reveal-revealed": revealed, + "decrypt-reveal-encrypted": encrypted, + "decrypt-reveal-decrypted": !!decrypted, + }); + + return revealed + ? + : (encrypted + ? {revealLink} + : revealLink + ) +} diff --git a/src/pages/wallets/info/WalletDescAdvancedInfo.tsx b/src/pages/wallets/info/WalletDescAdvancedInfo.tsx new file mode 100644 index 0000000..4bd419f --- /dev/null +++ b/src/pages/wallets/info/WalletDescAdvancedInfo.tsx @@ -0,0 +1,38 @@ +// 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 React from "react"; +import { Descriptions } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { OptionalField } from "@comp/OptionalField"; +import { DecryptReveal } from "./DecryptReveal"; +import { BooleanText } from "./BooleanText"; + +import { WalletDescProps } from "./WalletInfoModal"; + +export function WalletDescAdvancedInfo({ wallet, descProps }: WalletDescProps): JSX.Element { + const { t } = useTranslation(); + + return + {/* Wallet Encrypted Password */} + + + }/> + + + {/* Wallet Encrypted Private key */} + + + }/> + + + {/* Wallet Saved */} + + + + ; +} diff --git a/src/pages/wallets/info/WalletDescBasicInfo.tsx b/src/pages/wallets/info/WalletDescBasicInfo.tsx new file mode 100644 index 0000000..b84fe3a --- /dev/null +++ b/src/pages/wallets/info/WalletDescBasicInfo.tsx @@ -0,0 +1,49 @@ +// 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 React from "react"; +import { Descriptions } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { OptionalField } from "@comp/OptionalField"; +import { DecryptReveal } from "./DecryptReveal"; + +import { WalletDescProps } from "./WalletInfoModal"; + +export function WalletDescBasicInfo({ wallet, descProps }: WalletDescProps): JSX.Element { + const { t } = useTranslation(); + + return + {/* Wallet UUID */} + + + + + {/* Wallet Label */} + + + + {/* Wallet Category */} + + + + + {/* Wallet Username */} + + + + {/* Wallet Password */} + + + + {/* Wallet Private Key */} + + + + {/* Wallet Format */} + + + + ; +} diff --git a/src/pages/wallets/info/WalletDescSyncedInfo.tsx b/src/pages/wallets/info/WalletDescSyncedInfo.tsx new file mode 100644 index 0000000..d510735 --- /dev/null +++ b/src/pages/wallets/info/WalletDescSyncedInfo.tsx @@ -0,0 +1,65 @@ +// 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 React from "react"; +import { Descriptions } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { OptionalField } from "@comp/OptionalField"; +import { KristValue } from "@comp/krist/KristValue"; +import { DateTime } from "@comp/DateTime"; +import { BooleanText } from "./BooleanText"; + +import { WalletDescProps } from "./WalletInfoModal"; + +export function WalletDescSyncedInfo({ wallet, descProps }: WalletDescProps): JSX.Element { + const { t } = useTranslation(); + + return + {/* Wallet Address */} + + + + + {/* Wallet Balance */} + + + : undefined} + /> + + + {/* Wallet Name Count */} + + + + + {/* Wallet First Seen */} + + + : undefined} + /> + + + {/* Wallet Exists on network */} + + + + + {/* Wallet Last Synced */} + + + : undefined} + /> + + ; +} diff --git a/src/pages/wallets/info/WalletInfoModal.less b/src/pages/wallets/info/WalletInfoModal.less new file mode 100644 index 0000000..b184abf --- /dev/null +++ b/src/pages/wallets/info/WalletInfoModal.less @@ -0,0 +1,19 @@ +// 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 (reference) "../../../App.less"; + +.wallet-info-modal { + .ant-modal-body { + max-height: 500px; + overflow-y: auto; + } + + .ant-descriptions { + margin-bottom: @margin-lg; + + .ant-descriptions-header { + margin-bottom: @padding-xs; + } + } +} diff --git a/src/pages/wallets/info/WalletInfoModal.tsx b/src/pages/wallets/info/WalletInfoModal.tsx new file mode 100644 index 0000000..1c05cd3 --- /dev/null +++ b/src/pages/wallets/info/WalletInfoModal.tsx @@ -0,0 +1,63 @@ +// 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 React, { Dispatch, SetStateAction } from "react"; +import { Modal, Button, DescriptionsProps } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { Wallet } from "@wallets"; +import { WalletDescBasicInfo } from "./WalletDescBasicInfo"; +import { WalletDescSyncedInfo } from "./WalletDescSyncedInfo"; +import { WalletDescAdvancedInfo } from "./WalletDescAdvancedInfo"; + +import "./WalletInfoModal.less"; + +export interface WalletDescProps { + wallet: Wallet; + descProps: DescriptionsProps; +} + +interface Props { + wallet: Wallet; + + visible: boolean; + setVisible: Dispatch>; +} + +export function WalletInfoModal({ + wallet, + visible, setVisible +}: Props): JSX.Element { + const { t } = useTranslation(); + + const closeModal = () => setVisible(false); + + const descProps: DescriptionsProps = { + column: 1, + size: "small", + bordered: true + }; + + return + {t("dialog.close")} + + ]} + + onCancel={closeModal} + destroyOnClose + > + + + + ; +} diff --git a/src/store/reducers/WalletsReducer.ts b/src/store/reducers/WalletsReducer.ts index 4d9d537..63fc2ea 100644 --- a/src/store/reducers/WalletsReducer.ts +++ b/src/store/reducers/WalletsReducer.ts @@ -2,7 +2,7 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import * as actions from "@actions/WalletsActions"; -import { createReducer, ActionType } from "typesafe-actions"; +import { createReducer } from "typesafe-actions"; import { Wallet, WalletMap, loadWallets, WALLET_UPDATABLE_KEYS, WALLET_SYNCABLE_KEYS } from "@wallets";