diff --git a/.vscode/settings.json b/.vscode/settings.json index 6351656..4e05f85 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "Algo", "Authed", "Authorise", "Inequal", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index aaa3a60..dfc4b56 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -125,7 +125,10 @@ "walletLabelPlaceholder": "Wallet label (optional)", "walletCategory": "Wallet category", "walletCategoryDropdownNone": "No category", + "walletCategoryDropdownNew": "New", + "walletCategoryDropdownNewPlaceholder": "Category name", + "walletAddress": "Wallet address", "walletPassword": "Wallet password", "walletPasswordPlaceholder": "Wallet password", "walletPasswordWarning": "Make sure to save this somewhere <1>secure!", @@ -137,7 +140,7 @@ "walletFormat": "Wallet format", "walletFormatKristWallet": "KristWallet, KWallet (recommended)", - "walletFormatRaw": "Raw (advanced users)", + "walletFormatApi": "Raw/API (advanced users)", "walletSave": "Save this wallet in KristWeb" }, diff --git a/src/components/wallets/WalletCategoryDropdown.tsx b/src/components/wallets/WalletCategoryDropdown.tsx new file mode 100644 index 0000000..9205b2c --- /dev/null +++ b/src/components/wallets/WalletCategoryDropdown.tsx @@ -0,0 +1,73 @@ +import React, { useState } from "react"; +import { Select, Input, Button, Typography, Divider } from "antd"; +import { PlusOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +const { Text } = Typography; + +interface Props { + onNewCategory?: (name: string) => void; +} + +export function getWalletCategoryDropdown({ onNewCategory }: Props): JSX.Element { + const { t } = useTranslation(); + const [input, setInput] = useState(); + const [categories, setCategories] = useState(["Test category"]); + + function addCategory() { + if (!input) return; + + const categoryName = input.trim(); + if (!categoryName || categoryName.length > 32 + || categories.includes(categoryName)) return; + + const newCategories = [...categories, categoryName]; + newCategories.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true })); + + setCategories(newCategories); + setInput(undefined); + + // TODO: fix bug where hitting enter will _sometimes_ not set the right + // category name on the form + + if (onNewCategory) onNewCategory(categoryName); + } + + return setInput(e.target.value)} + onPressEnter={e => { e.preventDefault(); addCategory(); }} + + placeholder={t("addWallet.walletCategoryDropdownNewPlaceholder")} + + size="small" + style={{ flex: 1, height: 24 }} + /> + + + + + ) + }> + + {t("addWallet.walletCategoryDropdownNone")} + + + {categories.map(c => + {c} + )} + ; +} diff --git a/src/krist/AddressAlgo.ts b/src/krist/AddressAlgo.ts new file mode 100644 index 0000000..0775d0b --- /dev/null +++ b/src/krist/AddressAlgo.ts @@ -0,0 +1,31 @@ +import { sha256, doubleSHA256 } from "../utils/crypto"; + +const hexToBase36 = (input: number): string => { + const byte = 48 + Math.floor(input / 7); + return String.fromCharCode(byte + 39 > 122 ? 101 : byte > 57 ? byte + 39 : byte); +}; + +export const makeV2Address = async (key: string): Promise => { + const chars = ["", "", "", "", "", "", "", "", ""]; + let chain = "k"; // TODO: custom prefixes + let hash = await doubleSHA256(key); + + for (let i = 0; i <= 8; i++) { + chars[i] = hash.substring(0, 2); + hash = await doubleSHA256(hash); + } + + for (let i = 0; i <= 8;) { + const index = parseInt(hash.substring(2 * i, 2 + (2 * i)), 16) % 9; + + if (chars[index] === "") { + hash = await sha256(hash); + } else { + chain += hexToBase36(parseInt(chars[index], 16)); + chars[index] = ""; + i++; + } + } + + return chain; +}; diff --git a/src/krist/wallets/formats/WalletFormat.ts b/src/krist/wallets/formats/WalletFormat.ts index cc9c8fd..0622b3c 100644 --- a/src/krist/wallets/formats/WalletFormat.ts +++ b/src/krist/wallets/formats/WalletFormat.ts @@ -15,3 +15,7 @@ "kristwallet": KristWalletFormat, "api": APIFormat }; + +export const applyWalletFormat = + (format: WalletFormatName, password: string): Promise => + WalletFormatMap[format](password); diff --git a/src/pages/wallets/AddWalletModal.tsx b/src/pages/wallets/AddWalletModal.tsx index a121ecc..cd7ae0c 100644 --- a/src/pages/wallets/AddWalletModal.tsx +++ b/src/pages/wallets/AddWalletModal.tsx @@ -1,6 +1,6 @@ -import React, { useRef, useEffect } from "react"; -import { Modal, Form, Input, Collapse, Select, Button, Tooltip, Typography } from "antd"; -import { CopyOutlined, ReloadOutlined } from "@ant-design/icons"; +import React, { useState, useRef, useEffect } from "react"; +import { Modal, Form, Input, Checkbox, Collapse, Select, Button, Tooltip, Typography, Row, Col } from "antd"; +import { ReloadOutlined } from "@ant-design/icons"; import { useTranslation, Trans } from "react-i18next"; @@ -8,15 +8,20 @@ import { FakeUsernameInput } from "../../components/auth/FakeUsernameInput"; import { CopyInputButton } from "../../components/CopyInputButton"; -import { WalletFormat } from "../../krist/wallets/formats/WalletFormat"; +import { getWalletCategoryDropdown } from "../../components/wallets/WalletCategoryDropdown"; +import { WalletFormatName, applyWalletFormat } from "../../krist/wallets/formats/WalletFormat"; +import { makeV2Address } from "../../krist/AddressAlgo"; const { Text } = Typography; interface FormValues { label?: string; + category: string; password: string; - format: WalletFormat; + format: WalletFormatName; + + save: boolean; } interface Props { @@ -30,6 +35,7 @@ const { t } = useTranslation(); const [form] = Form.useForm(); const passwordInput = useRef(null); + const [calculatedAddress, setCalculatedAddress] = useState(); async function onSubmit() { const values = await form.validateFields(); @@ -39,10 +45,23 @@ setVisible(false); } + function onValuesChange(changed: Partial, values: Partial) { + if ((changed.format || changed.password) && values.password) + updateCalculatedAddress(values.format, values.password); + } + + /** Update the 'Wallet address' field */ + async function updateCalculatedAddress(format: WalletFormatName | undefined, password: string) { + const privatekey = await applyWalletFormat(format || "kristwallet", password); + const address = await makeV2Address(privatekey); + setCalculatedAddress(address); + } + function generateNewPassword() { if (!create || !form) return; const password = generatePassword(); form.setFieldsValue({ password }); + updateCalculatedAddress("kristwallet", password); } // Generate a password when the modal is opened @@ -68,16 +87,35 @@ name={create ? "createWalletForm" : "addWalletForm"} initialValues={{ - format: "kristwallet" + category: "", + format: "kristwallet", + save: true }} + + onValuesChange={onValuesChange} > - {/* Wallet label */} - - - + + {/* Wallet label */} + + + + + + + {/* Wallet category */} + + + {getWalletCategoryDropdown({ onNewCategory: category => form.setFieldsValue({ category })})} + + + + {/* Fake username input to trick browser autofill */} @@ -113,20 +151,26 @@ Make sure to save this somewhere secure! } + {/* Calculated address */} + + + + {/* Advanced options */} {!create && {/* Wallet format */} - + + + {/* Save in KristWeb checkbox */} + + {t("addWallet.walletSave")} + } diff --git a/src/style/components.less b/src/style/components.less index aa7434a..8d6e34a 100644 --- a/src/style/components.less +++ b/src/style/components.less @@ -47,6 +47,10 @@ background: @kw-input-readonly-bg; } +.ant-input.ant-input-sm::placeholder { + font-size: @font-size-sm; +} + .input-monospace { font-family: monospace; } @@ -87,7 +91,7 @@ }; } -.ant-modal-body .ant-input-group.ant-input-group-compact .ant-btn { +.ant-input-group.ant-input-group-compact .ant-btn { border-left: 1px solid @modal-content-bg; &.ant-btn-icon-only { diff --git a/src/style/theme.less b/src/style/theme.less index 8e2c36a..08ad69c 100644 --- a/src/style/theme.less +++ b/src/style/theme.less @@ -78,6 +78,9 @@ @btn-font-size-sm: @font-size-sm; @btn-line-height: 1.3; +@select-item-selected-bg: @kw-lighter; +@select-item-active-bg: @kw-slighter; + @table-header-bg: @kw-darker; @table-header-sort-bg: @kw-darkest; @table-header-filter-active-bg: @kw-darkest;