diff --git a/.vscode/settings.json b/.vscode/settings.json index c89e1c9..4cfb1d3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "motd", "multiline", "singleline", - "tsdoc" + "tsdoc", + "typesafe" ], } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 34560ad..e9ea160 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1830,6 +1830,16 @@ "integrity": "sha512-2xtoL22/3Mv6a70i4+4RB7VgbDDORoWwjcqeNysojZA0R7NK17RbY5Gof/2QiFfJgX+KkWghbwJ+d/2SB8Ndzg==", "dev": true }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "dev": true, + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/invariant": { "version": "2.2.34", "resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.34.tgz", @@ -1914,6 +1924,18 @@ "@types/react": "*" } }, + "@types/react-redux": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.9.tgz", + "integrity": "sha512-mpC0jqxhP4mhmOl3P4ipRsgTgbNofMRXJb08Ms6gekViLj61v1hOZEKWDCyWsdONr6EjEA6ZHXC446wdywDe0w==", + "dev": true, + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, "@types/react-router": { "version": "5.1.8", "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.8.tgz", @@ -11664,6 +11686,18 @@ "warning": "^4.0.3" } }, + "react-redux": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.1.tgz", + "integrity": "sha512-T+VfD/bvgGTUA74iW9d2i5THrDQWbweXP0AVNI8tNd1Rk5ch1rnMiJkDD67ejw7YBKM4+REvcvqRuWJb7BLuEg==", + "requires": { + "@babel/runtime": "^7.5.5", + "hoist-non-react-statics": "^3.3.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^16.9.0" + } + }, "react-router": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz", @@ -11950,6 +11984,15 @@ "strip-indent": "^3.0.0" } }, + "redux": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", + "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==", + "requires": { + "loose-envify": "^1.4.0", + "symbol-observable": "^1.2.0" + } + }, "regenerate": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.1.tgz", @@ -13483,6 +13526,11 @@ "util.promisify": "~1.0.0" } }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + }, "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -13903,6 +13951,11 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, + "typesafe-actions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/typesafe-actions/-/typesafe-actions-5.1.0.tgz", + "integrity": "sha512-bna6Yi1pRznoo6Bz1cE6btB/Yy8Xywytyfrzu/wc+NFW3ZF0I+2iCGImhBsoYYCOWuICtRO4yHcnDlzgo1AdNg==" + }, "typescript": { "version": "3.9.7", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz", diff --git a/package.json b/package.json index 644d8ae..d973470 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,14 @@ "react": "^16.13.1", "react-bootstrap": "^1.3.0", "react-dom": "^16.13.1", + "react-redux": "^7.2.1", "react-router-bootstrap": "^0.25.0", "react-router-dom": "^5.2.0", "react-scripts": "^3.4.3", + "redux": "^4.0.5", "semver": "^7.3.2", "spu-md5": "0.0.4", + "typesafe-actions": "^5.1.0", "typescript": "^3.9.7", "websocket-as-promised": "^1.0.1" }, @@ -65,6 +68,7 @@ "@types/prop-types": "^15.7.3", "@types/react": "^16.9.49", "@types/react-dom": "^16.9.8", + "@types/react-redux": "^7.1.9", "@types/react-router-bootstrap": "^0.24.5", "@types/react-router-dom": "^5.1.5", "@types/semver": "^7.3.3", diff --git a/src/app/App.tsx b/src/app/App.tsx index f4f1a99..88814f5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,39 +1,27 @@ -import React, { Component } from "react"; +import React from "react"; import "./App.scss"; import { MainLayout } from "../layouts/main"; -import { WalletManager } from "./WalletManager"; -import { WalletManagerView } from "./WalletManagerView"; +import { MasterPasswordDialog } from "@layouts/dialogs/MasterPasswordDialog"; -import { kristService } from "@krist/KristConnectionService"; +// import { kristService } from "@krist/KristConnectionService"; -import packageJson from "@/package.json"; +import { createStore } from "redux"; +import { Provider } from "react-redux"; +import rootReducer from "@/src/store/reducers/RootReducer"; + +export const store = createStore(rootReducer); +export type AppDispatch = typeof store.dispatch; + +/*import packageJson from "@/package.json"; kristService().connect(packageJson.defaultSyncNode) // TODO - .catch(console.error); - -interface AppState { - walletManager: WalletManager; -} - -export class App extends Component { - constructor(props: unknown) { - super(props); - - this.state = { - walletManager: new WalletManager((walletManager: WalletManager) => { - this.setState({ walletManager }); - }) - }; - } - - render(): JSX.Element { - const { walletManager } = this.state; - - return <> - - - ; - } -} + .catch(console.error);*/ + +export const App: React.FC = () => ( + + + + +); diff --git a/src/app/WalletManager.tsx b/src/app/WalletManager.tsx index 5d61854..d51c19d 100644 --- a/src/app/WalletManager.tsx +++ b/src/app/WalletManager.tsx @@ -1,105 +1,57 @@ import { toHex } from "@utils"; import { aesGcmEncrypt, aesGcmDecrypt } from "@utils/crypto"; +import { AppDispatch } from "./App"; +import * as actions from "@actions/WalletManagerActions"; + import Debug from "debug"; const debug = Debug("kristweb:walletManager"); -export class WalletManager { - /** Whether or not the user has logged in, either as a guest, or with a - * master password. */ - isLoggedIn = false; +export function browseAsGuest(dispatch: AppDispatch): void { + dispatch(actions.browseAsGuest()); +} - /** Whether or not the user is browsing KristWeb as a guest. */ - isGuest = true; +/** Verifies that the given password is correct, and dispatches the login + * action to the Redux store. */ +export async function login(dispatch: AppDispatch, salt: string | undefined, tester: string | undefined, password: string): Promise { + if (!password) throw new Error("Password is required."); + if (!salt || !tester) throw new Error("Master password has not been set up."); - /** The master password used to encrypt and decrypt local storage data. */ - masterPassword?: string; - - /** Secure random string that is encrypted with the master password to create - * the "tester" string. */ - salt?: string; - /** The `salt` encrypted with the master password, to test the password is - * correct. */ - tester?: string; + try { + // Attempt to decrypt the tester with the given password + const testerDec = await aesGcmDecrypt(tester, password); - /** Whether or not the user has configured and saved a master password - * before (whether or not salt+tester are present in local storage). */ - hasMasterPassword = false; - - constructor(private stateChangeListener: (walletManager: WalletManager) => void) { - this.isLoggedIn = false; - this.isGuest = true; - - // Salt and tester from local storage (or undefined) - this.salt = localStorage.getItem("salt") || undefined; - this.tester = localStorage.getItem("tester") || undefined; - - // There is a master password configured if both `salt` and `tester` exist - this.hasMasterPassword = !!this.salt && !!this.tester; - - debug("hasMasterPassword: %b", this.hasMasterPassword); + // Verify that the decrypted tester is equal to the salt, if not, the + // provided master password is incorrect. + if (testerDec !== salt) throw new Error("Incorrect password."); + } catch (e) { + // OperationError usually means decryption failure + if (e.name === "OperationError") throw new Error("Incorrect password."); + else throw e; } - async setMasterPassword(password: string): Promise { - if (!password) throw new Error("Password is required."); + // Dispatch the login state changes to the Redux store + dispatch(actions.login(password)); +} - // Generate the salt (to be encrypted with the master password) - const salt = window.crypto.getRandomValues(new Uint8Array(32)); - const saltHex = toHex(salt); +/** Generates a salt and tester, sets the master password, and dispatches the + * action to the Redux store. */ +export async function setMasterPassword(dispatch: AppDispatch, password: string): Promise { + if (!password) throw new Error("Password is required."); - // Generate the encryption tester - const tester = await aesGcmEncrypt(saltHex, password); + // Generate the salt (to be encrypted with the master password) + const salt = window.crypto.getRandomValues(new Uint8Array(32)); + const saltHex = toHex(salt); - debug("master password salt: %x tester: %s", salt, tester); + // Generate the encryption tester + const tester = await aesGcmEncrypt(saltHex, password); - // Store them in local storage - localStorage.setItem("salt", saltHex); - localStorage.setItem("tester", tester); + debug("master password salt: %x tester: %s", salt, tester); - // Set the logged in state - this.isLoggedIn = true; - this.isGuest = false; - this.masterPassword = password; + // Store them in local storage + localStorage.setItem("salt", saltHex); + localStorage.setItem("tester", tester); - // Delegate to the App's listener - this.stateChangeListener(this); - } - - async testMasterPassword(password: string): Promise { - if (!password) throw new Error("Password is required."); - - // Get the salt and tester from local storage and ensure they exist - const { salt, tester } = this; - if (!salt || !tester) throw new Error("Master password has not been set up."); - - try { - // Attempt to decrypt the tester with the given password - const testerDec = await aesGcmDecrypt(tester, password); - - // Verify that the decrypted tester is equal to the salt, if not, the - // provided master password is incorrect. - if (testerDec !== salt) throw new Error("Incorrect password."); - } catch (e) { - // OperationError usually means decryption failure - if (e.name === "OperationError") throw new Error("Incorrect password."); - else throw e; - } - - // Set the logged in state and don't return any errors (login successful) - this.isLoggedIn = true; - this.isGuest = false; - this.masterPassword = password; - - // Delegate to the App's listener - this.stateChangeListener(this); - } - - browseAsGuest(): void { - // Set the logged in state as a guest - this.isLoggedIn = true; - this.isGuest = true; - - // Delegate to the App's listener - this.stateChangeListener(this); - } -}; + // Dispatch the login state changes to the Redux store + dispatch(actions.setMasterPassword(saltHex, tester, password)); +} diff --git a/src/app/WalletManagerView.tsx b/src/app/WalletManagerView.tsx deleted file mode 100644 index 4aa0b00..0000000 --- a/src/app/WalletManagerView.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React, { Component } from "react"; - -import { MasterPasswordDialog } from "../layouts/dialogs/MasterPasswordDialog"; -import { WalletManager } from "./WalletManager"; - -interface Props { - walletManager: WalletManager; -} - -export class WalletManagerView extends Component { - /** Render the master password login/setup dialog */ - render(): JSX.Element | null { - const { walletManager } = this.props; - const { isLoggedIn, hasMasterPassword } = walletManager; - if (isLoggedIn) return null; // Don't show the dialog again - - return ( - - ); - } -} diff --git a/src/layouts/dialogs/MasterPasswordDialog.tsx b/src/layouts/dialogs/MasterPasswordDialog.tsx index 2cfe6ab..986e1ac 100644 --- a/src/layouts/dialogs/MasterPasswordDialog.tsx +++ b/src/layouts/dialogs/MasterPasswordDialog.tsx @@ -1,4 +1,4 @@ -import React, { Component } from "react"; +import React, { Component, ReactNode } from "react"; import Modal from "react-bootstrap/Modal"; import Button from "react-bootstrap/Button"; @@ -8,29 +8,48 @@ import { HelpWalletStorageLink } from "./HelpWalletStorageDialog"; -import { WalletManager } from "../../app/WalletManager"; +import { Dispatch } from "redux"; +import { connect, ConnectedProps } from "react-redux"; +import { RootState } from "@store"; +import { browseAsGuest, login, setMasterPassword } from "@app/WalletManager"; -interface MasterPasswordDialogProps { +interface OwnProps { + isLoggedIn: boolean; + salt?: string; + tester?: string; hasMasterPassword: boolean; - walletManager: WalletManager; } +const mapStateToProps = (state: RootState): OwnProps => ({ + isLoggedIn: state.walletManager.isLoggedIn, + salt: state.walletManager.salt, + tester: state.walletManager.tester, + hasMasterPassword: state.walletManager.hasMasterPassword +}); + +const mapDispatchToProps = (dispatch: Dispatch) => { + return { dispatch }; +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +type Props = ConnectedProps & OwnProps; + interface FormValues { password: string; } -export class MasterPasswordDialog extends Component { +class MasterPasswordDialogComponent extends Component { async onSubmit({ password }: FormValues, helpers: FormikHelpers): Promise { try { if (typeof password !== "string" || password.length === 0) throw new Error("Password is required."); - const { hasMasterPassword, walletManager } = this.props; + const { dispatch, salt, tester, hasMasterPassword } = this.props; if (hasMasterPassword) // Attempt login - await walletManager.testMasterPassword(password); + await login(dispatch, salt, tester, password); else // Setup a new master password - await walletManager.setMasterPassword(password); + await setMasterPassword(dispatch, password); } catch (e) { // Catch any errors (usually 'invalid password') and display helpers.setSubmitting(false); helpers.setErrors({ password: e.message || "Unknown error." }); @@ -39,10 +58,13 @@ } browseAsGuest(): void { - this.props.walletManager.browseAsGuest(); + browseAsGuest(this.props.dispatch); } - render(): JSX.Element { + render(): ReactNode { + // Don't show the dialog if we're already logged in + if (this.props.isLoggedIn) return null; + const { hasMasterPassword } = this.props; const body = hasMasterPassword ?

Enter your master password to access your wallets, or browse @@ -117,3 +139,5 @@ ); } } + +export const MasterPasswordDialog = connector(MasterPasswordDialogComponent); diff --git a/src/layouts/main/components/nav/index.tsx b/src/layouts/main/components/nav/index.tsx index c2e2715..b5a1883 100644 --- a/src/layouts/main/components/nav/index.tsx +++ b/src/layouts/main/components/nav/index.tsx @@ -9,13 +9,20 @@ import { ConnectionIndicator } from "./ConnectionIndicator"; import { SettingsCog } from "./SettingsCog"; +import { connect } from "react-redux"; +import { RootState } from "@store"; + import "./index.scss"; interface Props { isGuest: boolean; } -export const MainNav: React.FC = ({ isGuest }: Props): JSX.Element => ( +const mapStateToProps = (state: RootState): Props => ({ + isGuest: state.walletManager.isGuest +}); + +const MainNavComponent: React.FC = ({ isGuest }: Props): JSX.Element => ( @@ -38,3 +45,5 @@ ); + +export const MainNav = connect(mapStateToProps)(MainNavComponent); diff --git a/src/layouts/main/components/sidebar/index.tsx b/src/layouts/main/components/sidebar/index.tsx index 200d72d..c0c47c9 100644 --- a/src/layouts/main/components/sidebar/index.tsx +++ b/src/layouts/main/components/sidebar/index.tsx @@ -2,24 +2,29 @@ import Nav from "react-bootstrap/Nav"; -import { WalletManager } from "@app/WalletManager"; - import { GuestIndicator } from "./GuestIndicator"; import { TotalBalance } from "./TotalBalance"; import { SidebarItem } from "./SidebarItem"; import { Footer } from "./Footer"; +import { connect } from "react-redux"; +import { RootState } from "@store"; + import "./index.scss"; interface Props { - walletManager: WalletManager; + isGuest: boolean; } -export const MainSidebar: React.FC = (props: Props): JSX.Element => ( +const mapStateToProps = (state: RootState): Props => ({ + isGuest: state.walletManager.isGuest +}); + +const MainSidebarComponent: React.FC = ({ isGuest }: Props): JSX.Element => (

); + +export const MainSidebar = connect(mapStateToProps)(MainSidebarComponent); diff --git a/src/layouts/main/index.tsx b/src/layouts/main/index.tsx index 4a4fb93..6753fc4 100644 --- a/src/layouts/main/index.tsx +++ b/src/layouts/main/index.tsx @@ -6,8 +6,6 @@ import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; -import { WalletManager } from "@app/WalletManager"; - import { MainNav } from "./components/nav"; import { MainSidebar } from "./components/sidebar"; @@ -15,16 +13,12 @@ import "./index.scss"; -interface Props { - walletManager: WalletManager; -} - -export const MainLayout: React.FC = (props: Props): JSX.Element => ( +export const MainLayout: React.FC = () => ( - + - + diff --git a/src/store/actions/WalletManagerActions.ts b/src/store/actions/WalletManagerActions.ts new file mode 100644 index 0000000..9cca599 --- /dev/null +++ b/src/store/actions/WalletManagerActions.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { createAction } from "typesafe-actions"; + +import * as constants from "../constants"; + +export const browseAsGuest = createAction(constants.BROWSE_AS_GUEST)(); + +export interface LoginPayload { password: string }; +export const login = createAction(constants.LOGIN, + (password: string): LoginPayload => ({ password }))(); + +export interface SetMasterPasswordPayload { + salt: string; + tester: string; + password: string; +}; +export const setMasterPassword = createAction(constants.SET_MASTER_PASSWORD, + (salt: string, tester: string, password: string): SetMasterPasswordPayload => + ({ salt, tester, password }))(); diff --git a/src/store/actions/index.ts b/src/store/actions/index.ts new file mode 100644 index 0000000..c391f58 --- /dev/null +++ b/src/store/actions/index.ts @@ -0,0 +1,5 @@ +import * as walletManagerActions from "./WalletManagerActions"; + +export default { + walletManager: walletManagerActions +}; diff --git a/src/store/constants.ts b/src/store/constants.ts new file mode 100644 index 0000000..e33f180 --- /dev/null +++ b/src/store/constants.ts @@ -0,0 +1,6 @@ +// ----------------------------------------------------------------------------- +// Wallet Manager +// ----------------------------------------------------------------------------- +export const BROWSE_AS_GUEST = "BROWSE_AS_GUEST"; +export const LOGIN = "LOGIN"; +export const SET_MASTER_PASSWORD = "SET_MASTER_PASSWORD"; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..d8bfae5 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,5 @@ +import { ActionType, StateType } from "typesafe-actions"; + +export type Store = StateType; +export type RootAction = ActionType; +export type RootState = StateType>; diff --git a/src/store/reducers/RootReducer.ts b/src/store/reducers/RootReducer.ts new file mode 100644 index 0000000..4d2049b --- /dev/null +++ b/src/store/reducers/RootReducer.ts @@ -0,0 +1,6 @@ +import { combineReducers } from "redux"; +import { WalletManagerReducer } from "@reducers/WalletManagerReducer"; + +export default combineReducers({ + walletManager: WalletManagerReducer +}); diff --git a/src/store/reducers/WalletManagerReducer.ts b/src/store/reducers/WalletManagerReducer.ts new file mode 100644 index 0000000..4311279 --- /dev/null +++ b/src/store/reducers/WalletManagerReducer.ts @@ -0,0 +1,64 @@ +import { browseAsGuest, login, setMasterPassword } from "@actions/WalletManagerActions"; +import { createReducer, ActionType } from "typesafe-actions"; + +export interface State { + /** Whether or not the user has logged in, either as a guest, or with a + * master password. */ + readonly isLoggedIn: boolean; + + /** Whether or not the user is browsing KristWeb as a guest. */ + readonly isGuest: boolean; + + /** The master password used to encrypt and decrypt local storage data. */ + readonly masterPassword?: string; + + /** Secure random string that is encrypted with the master password to create + * the "tester" string. */ + readonly salt?: string; + /** The `salt` encrypted with the master password, to test the password is + * correct. */ + readonly tester?: string; + + /** Whether or not the user has configured and saved a master password + * before (whether or not salt+tester are present in local storage). */ + readonly hasMasterPassword: boolean; +} + +// Salt and tester from local storage (or undefined) +const salt = localStorage.getItem("salt") || undefined; +const tester = localStorage.getItem("tester") || undefined; + +// There is a master password configured if both `salt` and `tester` exist +const hasMasterPassword = !!salt && !!tester; + +const initialState: State = { + isLoggedIn: false, + isGuest: true, + + salt, + tester, + + hasMasterPassword +}; + +export const WalletManagerReducer = createReducer(initialState) + .handleAction(browseAsGuest, (state: State) => ({ + ...state, + isLoggedIn: true, + isGuest: true + })) + .handleAction(login, (state: State, action: ActionType) => ({ + ...state, + isLoggedIn: true, + isGuest: false, + masterPassword: action.payload.password + })) + .handleAction(setMasterPassword, (state: State, action: ActionType) => ({ + ...state, + isLoggedIn: true, + isGuest: false, + masterPassword: action.payload.password, + salt: action.payload.salt, + tester: action.payload.tester, + hasMasterPassword: true + })); diff --git a/src/store/types.d.ts b/src/store/types.d.ts new file mode 100644 index 0000000..7c33332 --- /dev/null +++ b/src/store/types.d.ts @@ -0,0 +1,9 @@ +import { Store, RootAction, RootState } from "./"; + +declare module "typesafe-actions" { + interface Types { + Store: Store; + RootAction: RootAction; + RootState: RootState; + } +}; diff --git a/tsconfig.extend.json b/tsconfig.extend.json index 4241b0c..5ea5a54 100644 --- a/tsconfig.extend.json +++ b/tsconfig.extend.json @@ -8,6 +8,10 @@ "@krist/*": ["./src/krist/*"], "@utils": ["./src/utils/index.ts"], "@utils/*": ["./src/utils/*"], + "@actions/*": ["./src/store/actions/*"], + "@reducers/*": ["./src/store/reducers/*"], + "@store": ["./src/store"], + "@store/*": ["./src/store/*"], "@/*": ["./*"] } }