diff --git a/.eslintrc.json b/.eslintrc.json index 7b455b9..2b1117a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -27,6 +27,7 @@ }], "eol-last": ["error", "always"], "object-shorthand": ["error", "always"], + "no-unused-vars": 0, "tsdoc/syntax": "warn", "react/display-name": 0, "react/prop-types": 0, @@ -42,7 +43,12 @@ "@typescript-eslint/member-delimiter-style": ["error", { "multiline": {"delimiter": "semi", "requireLast": true}, "singleline": {"delimiter": "semi", "requireLast": false} - }] + }], + "@typescript-eslint/no-unused-vars": ["warn", { + "ignoreRestSiblings": true, + "argsIgnorePattern": "^_" + }], + "@typescript-eslint/no-non-null-assertion": 0 }, "extends": [ "eslint:recommended", diff --git a/.vscode/settings.json b/.vscode/settings.json index e76f3d0..6351656 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,21 +3,28 @@ "Authed", "Authorise", "Inequal", + "KRISTWALLET", "Lngs", "Sider", + "Syncable", "Transpiler", "antd", "anticon", "arraybuffer", "authorised", "clientside", + "dont", "languagedetector", "localisation", "multiline", "pnpm", "privatekeys", + "readonly", "singleline", "submenu", - "tsdoc" - ] + "tsdoc", + "typeahead" + ], + "i18next.defaultTranslatedLocale": "en", + "i18next.i18nPaths": "public/locales" } diff --git a/README.md b/README.md index b023a52..7eece89 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,9 @@ [IETF language tags](https://en.wikipedia.org/wiki/IETF_language_tag). Short tags (e.g. `en` instead of `en-GB`) are preferred. -**IMPORTANT:** If you are adding a new language, you **must**: - -* Add the language code to [`src/utils/i18n.ts`](src/utils/i18n.ts) in - `supportedLngs` -* Add a listing for the language with the English name, native name, a country - code (for the flag) and the contributors list to - [`languages.json`](languages.json) +**IMPORTANT:** If you are adding a new language, you **must** add a listing for +the language with the English name, native name, a country code (for the flag) +and the contributors list to [`languages.json`](languages.json). The library will automatically detect the language from your browser to use, but for the sake of testing, you can override it by running the following command in diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 1f0a5db..aaa3a60 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -37,7 +37,9 @@ }, "dialog": { - "close": "Close" + "close": "Close", + "ok": "OK", + "cancel": "Cancel" }, "pagination": { @@ -48,6 +50,9 @@ "error": "Error", "loading": "Loading...", + "copy": "Copy to clipboard", + "copied": "Copied!", + "typeahead": { "emptyLabel": "No matches found.", "paginationText": "Display additional results..." @@ -62,7 +67,7 @@ "logIn": "Log in", "forgotPassword": "Forgot password?", "intro": "Enter a master password to encrypt your wallets, or browse KristWeb as a guest <1>.", - "intro2": "Enter a <1>master password to encrypt your wallet privatekeys. They will be saved in your browser's local storage, and you will be asked for the master password to decrypt them once per session.", + "intro2": "Enter a <1>master password to encrypt your wallet private keys. They will be saved in your browser's local storage, and you will be asked for the master password to decrypt them once per session.", "dontForgetPassword": "Never forget this password. If you forget it, you will have to create a new password and add your wallets all over again.", "loginIntro": "Enter a master password to access your wallets, or browse KristWeb as a guest.", "learnMore": "learn more", @@ -76,8 +81,11 @@ "helpWalletStorageTitle": "Help: Wallet storage", "helpWalletStorage": "When you add a wallet to KristWeb, the private key for the wallet is saved to your browser's local storage and encrypted with your master password.\nEvery wallet you save is encrypted using the same master password, and you will need to enter it every time you open KristWeb. Your actual Krist wallet is not modified in any way.\nWhen browsing KristWeb as a guest, you do not need to enter a master password, but it also means that you will not be able to add or use any wallets. You will still be able to explore the Krist network.", "popoverTitle": "Decrypt wallets", + "popoverTitleEncrypt": "Encrypt wallets", "popoverAuthoriseButton": "Authorise", - "popoverDescription": "Enter your master password to decrypt your wallets." + "popoverDescription": "Enter your master password to decrypt your wallets.", + "popoverDescriptionEncrypt": "Enter your master password to encrypt and decrypt your wallets.", + "forcedAuthWarning": "You were automatically logged in by an insecure debug setting." }, "myWallets": { @@ -110,10 +118,28 @@ "addWallet": { "dialogTitle": "Add wallet", "dialogTitleCreate": "Create wallet", + "dialogOkAdd": "Add", + "dialogOkCreate": "Create", + "walletLabel": "Wallet label", "walletLabelPlaceholder": "Wallet label (optional)", "walletCategory": "Wallet category", - "walletCategoryDropdownNone": "No category" + "walletCategoryDropdownNone": "No category", + + "walletPassword": "Wallet password", + "walletPasswordPlaceholder": "Wallet password", + "walletPasswordWarning": "Make sure to save this somewhere <1>secure!", + "walletPasswordRegenerate": "Regenerate", + "walletPrivatekey": "Wallet private key", + "walletPrivatekeyPlaceholder": "Wallet private key", + + "advancedOptions": "Advanced options", + + "walletFormat": "Wallet format", + "walletFormatKristWallet": "KristWallet, KWallet (recommended)", + "walletFormatRaw": "Raw (advanced users)", + + "walletSave": "Save this wallet in KristWeb" }, "credits": { @@ -132,6 +158,7 @@ "siteTitle": "Settings", "title": "Settings", + "menuLanguage": "Language", "subMenuDebug": "Debug settings", "menuTranslations": "Translations", @@ -157,6 +184,7 @@ "breadcrumb": { "dashboard": "Dashboard", + "wallets": "Wallets", "settings": "Settings", "settingsDebug": "Debug", diff --git a/src/App.tsx b/src/App.tsx index 5a57049..a37440a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import "./App.less"; import { AppLayout } from "./layout/AppLayout"; +import { ForcedAuth } from "./components/auth/ForcedAuth"; export const store = createStore( rootReducer, @@ -27,6 +28,7 @@ + ; diff --git a/src/components/CopyInputButton.tsx b/src/components/CopyInputButton.tsx new file mode 100644 index 0000000..ea92f67 --- /dev/null +++ b/src/components/CopyInputButton.tsx @@ -0,0 +1,38 @@ +import React, { useState } from "react"; +import { Tooltip, Button, ButtonProps, Input } from "antd"; +import { CopyOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +type Props = ButtonProps & { + targetInput: React.RefObject; + refocusButton?: boolean; +} + +export function CopyInputButton({ targetInput, refocusButton, ...buttonProps }: Props): JSX.Element { + const { t } = useTranslation(); + const [showCopied, setShowCopied] = useState(false); + + function copy(e: React.MouseEvent) { + if (!targetInput.current) return; + + targetInput.current.select(); + document.execCommand("copy"); + + if (refocusButton === undefined || refocusButton) { + e.currentTarget.focus(); + } + + setShowCopied(true); + } + + return { + if (!visible && showCopied) setShowCopied(false); + }} + > + + + setCreateWalletVisible(true)}> + + + + + setAddWalletVisible(true)}> + + + + ; +} + +export function WalletsPage(): JSX.Element { + const { t } = useTranslation(); + + return } + > + Hello, world + ; +} diff --git a/src/store/actions/WalletsActions.ts b/src/store/actions/WalletsActions.ts new file mode 100644 index 0000000..ddf1df7 --- /dev/null +++ b/src/store/actions/WalletsActions.ts @@ -0,0 +1,26 @@ +import { createAction } from "typesafe-actions"; + +import * as constants from "../constants"; + +import { WalletMap } from "../reducers/WalletsReducer"; +import { Wallet, WalletSyncable, WalletUpdatable } from "../../krist/wallets/Wallet"; + +export interface LoadWalletsPayload { wallets: WalletMap }; +export const loadWallets = createAction(constants.LOAD_WALLETS, + (wallets): LoadWalletsPayload => ({ wallets }))(); + +export interface AddWalletPayload { wallet: Wallet }; +export const addWallet = createAction(constants.ADD_WALLET, + (wallet): AddWalletPayload => ({ wallet }))(); + +export interface RemoveWalletPayload { id: string }; +export const removeWallet = createAction(constants.REMOVE_WALLET, + (id): RemoveWalletPayload => ({ id }))(); + +export interface UpdateWalletPayload { id: string; wallet: WalletUpdatable }; +export const updateWallet = createAction(constants.UPDATE_WALLET, + (id, wallet): UpdateWalletPayload => ({ id, wallet }))(); + +export interface SyncWalletPayload { id: string; wallet: WalletSyncable }; +export const syncWallet = createAction(constants.SYNC_WALLET, + (id, wallet): SyncWalletPayload => ({ id, wallet }))(); diff --git a/src/store/actions/index.ts b/src/store/actions/index.ts index 693ede2..6c390ea 100644 --- a/src/store/actions/index.ts +++ b/src/store/actions/index.ts @@ -1,6 +1,8 @@ import * as walletManagerActions from "./WalletManagerActions"; +import * as walletsActions from "./WalletsActions"; const RootAction = { - walletManager: walletManagerActions + walletManager: walletManagerActions, + wallets: walletsActions, }; export default RootAction; diff --git a/src/store/constants.ts b/src/store/constants.ts index de7f233..d8a0fc3 100644 --- a/src/store/constants.ts +++ b/src/store/constants.ts @@ -2,3 +2,11 @@ // --- export const AUTH_MASTER_PASSWORD = "AUTH_MASTER_PASSWORD"; export const SET_MASTER_PASSWORD = "SET_MASTER_PASSWORD"; + +// Wallets +// --- +export const LOAD_WALLETS = "LOAD_WALLETS"; +export const ADD_WALLET = "ADD_WALLET"; +export const REMOVE_WALLET = "REMOVE_WALLET"; +export const UPDATE_WALLET = "UPDATE_WALLET"; +export const SYNC_WALLET = "SYNC_WALLET"; diff --git a/src/store/reducers/RootReducer.ts b/src/store/reducers/RootReducer.ts index 2a33c6a..ca387f5 100644 --- a/src/store/reducers/RootReducer.ts +++ b/src/store/reducers/RootReducer.ts @@ -1,7 +1,9 @@ import { combineReducers } from "redux"; import { WalletManagerReducer } from "./WalletManagerReducer"; +import { WalletsReducer } from "./WalletsReducer"; export default combineReducers({ - walletManager: WalletManagerReducer + walletManager: WalletManagerReducer, + wallets: WalletsReducer }); diff --git a/src/store/reducers/WalletsReducer.ts b/src/store/reducers/WalletsReducer.ts new file mode 100644 index 0000000..8513558 --- /dev/null +++ b/src/store/reducers/WalletsReducer.ts @@ -0,0 +1,56 @@ +import { addWallet, loadWallets, removeWallet, syncWallet, updateWallet } from "../actions/WalletsActions"; +import { createReducer, ActionType } from "typesafe-actions"; + +import { Wallet } from "../../krist/wallets/Wallet"; + +export interface WalletMap { [key: string]: Wallet } +export interface State { + readonly wallets: WalletMap; +} + +const initialState: State = { + wallets: {} +}; + +function assignNewWalletProperties(state: State, id: string, partialWallet: Partial) { + // Fetch the old wallet and assign the new properties + const { [id]: wallet } = state.wallets; + const newWallet = { ...wallet, ...partialWallet }; + return { + ...state, + wallets: { + ...state.wallets, + [id]: newWallet + } + }; +} + +export const WalletsReducer = createReducer(initialState) + // Load wallets + .handleAction(loadWallets, (state: State, { payload }: ActionType) => ({ + ...state, + wallets: { + ...state.wallets, + ...payload.wallets + } + })) + // Add wallet + .handleAction(addWallet, (state: State, { payload }: ActionType) => ({ + ...state, + wallets: { + ...state.wallets, + [payload.wallet.id]: payload.wallet + } + })) + // Remove wallet + .handleAction(removeWallet, (state: State, { payload }: ActionType) => { + // Get the wallets without the one we want to remove + const { [payload.id]: _, ...wallets } = state.wallets; + return { ...state, wallets }; + }) + // Update wallet + .handleAction(updateWallet, (state: State, { payload }: ActionType) => + assignNewWalletProperties(state, payload.id, payload.wallet)) + // Sync wallet + .handleAction(syncWallet, (state: State, { payload }: ActionType) => + assignNewWalletProperties(state, payload.id, payload.wallet)); diff --git a/src/style/components.less b/src/style/components.less index d2f0510..aa7434a 100644 --- a/src/style/components.less +++ b/src/style/components.less @@ -2,6 +2,11 @@ width: 100%; border: 1px solid @border-color-split; + + .ant-menu-item { + border-bottom: 1px solid @border-color-split; + margin-bottom: 0; + } } .ant-btn { @@ -14,33 +19,36 @@ background: lighten(@kw-lighter, 5%); } - &:focus { - background: lighten(@kw-lighter, 10%); - } + &:focus { background: lighten(@kw-lighter, 10%); } &.ant-btn-primary { background: @primary-color; - &:hover, &:focus { - background: lighten(@primary-color, 5%); - } - - &:focus { - background: lighten(@primary-color, 10%); - } + &:hover, &:focus { background: lighten(@primary-color, 5%); } + &:focus { background: lighten(@primary-color, 10%); } } &.ant-btn-dangerous { background: @error-color; - &:hover, &:focus { - background: lighten(@error-color, 5%); - } - - &:focus { - background: lighten(@error-color, 10%); - } + &:hover, &:focus { background: lighten(@error-color, 5%); } + &:focus { background: lighten(@error-color, 10%); } } + + &.ant-btn-background-ghost { + border-color: @kw-lighter; + + &:hover, &:focus { border-color: lighten(@kw-lighter, 10%); } + &:focus { border-color: lighten(@kw-lighter, 20%); } + } +} + +.ant-input:read-only { + background: @kw-input-readonly-bg; +} + +.input-monospace { + font-family: monospace; } .ant-empty-description { @@ -61,3 +69,35 @@ stroke: @kw-darkest; } } + +.ant-collapse.flush-collapse { + & > .ant-collapse-item > .ant-collapse-header { + padding: 0; + padding-left: 16px; + + .ant-collapse-arrow { + padding: 0; + top: 7px; + left: 0; + } + } + + & > .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box { + padding: @padding-sm 0; + }; +} + +.ant-modal-body .ant-input-group.ant-input-group-compact .ant-btn { + border-left: 1px solid @modal-content-bg; + + &.ant-btn-icon-only { + padding-left: @padding-sm; + padding-right: @padding-sm; + width: auto; + } +} + + +.text-small { + font-size: @font-size-sm; +} diff --git a/src/style/theme.less b/src/style/theme.less index af6e173..8e2c36a 100644 --- a/src/style/theme.less +++ b/src/style/theme.less @@ -35,7 +35,7 @@ @kw-border-color-division: #3f4661; @kw-border-color-darker: @kw-darkest; -@border-radius-base: 6px; +@border-radius-base: 2px; @component-background: @kw-darkest; @@ -69,11 +69,14 @@ @input-color: @text-color; @input-placeholder-color: @text-color-secondary; +@kw-input-readonly-bg: @kw-slighter; + @btn-default-color: @text-color; @btn-default-bg: @kw-lighter; @btn-default-border: transparent; @btn-font-size-sm: @font-size-sm; +@btn-line-height: 1.3; @table-header-bg: @kw-darker; @table-header-sort-bg: @kw-darkest; diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index e31bc18..e888386 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -33,7 +33,7 @@ .use(initReactI18next) .init({ fallbackLng: "en", - supportedLngs: ["en", "de", "ja", "vi", "fr"], + supportedLngs: Object.keys(getLanguages() || {}), debug: isLocalhost, diff --git a/src/utils/index.ts b/src/utils/index.ts index 21fefc6..4480562 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,3 +15,25 @@ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ ) ); + +/** + * Generates a secure random password based on a length and character set. + * + * Implementation mostly sourced from: {@link https://stackoverflow.com/a/51540480/1499974} + * + * See also: {@link https://github.com/chancejs/chancejs/issues/232#issuecomment-182500222} + * + * @param length - The desired length of the password. + * @param charset - A string containing all the characters the password may + * contain. +*/ +export function generatePassword( + length = 32, + charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-" +): string { + // NOTE: talk about correctness with modulo and its bias (the charset is 64 + // characters right now anyway) + return Array.from(crypto.getRandomValues(new Uint32Array(length))) + .map(x => charset[x % charset.length]) + .join(""); +}