diff --git a/.vscode/settings.json b/.vscode/settings.json index 069a242..f5306a5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cSpell.words": [ "Authed", "Authorise", + "Inequal", "Lngs", "Sider", "antd", @@ -12,6 +13,7 @@ "languagedetector", "localisation", "pnpm", + "privatekeys", "tsdoc" ] } diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index cdc22e6..2b1c6fe 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -53,17 +53,21 @@ "masterPassword": { "dialogTitle": "Master password", "passwordPlaceholder": "Master password", + "passwordConfirmPlaceholder": "Confirm master password", "browseAsGuest": "Browse as guest", "createPassword": "Create password", "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.", "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", "errorPasswordRequired": "Password is required.", + "errorPasswordLength": "Must be at least 1 character.", "errorPasswordUnset": "Master password has not been set up.", "errorPasswordIncorrect": "Incorrect password.", + "errorPasswordInequal": "Passwords must match.", "errorStorageCorrupt": "Wallet storage is corrupted.", "errorUnknown": "Unknown error.", "helpWalletStorageTitle": "Help: Wallet storage", diff --git a/src/components/AuthorisedAction.less b/src/components/AuthorisedAction.less deleted file mode 100644 index de85785..0000000 --- a/src/components/AuthorisedAction.less +++ /dev/null @@ -1,9 +0,0 @@ -.authorised-action-popover { - width: 320px; - - .ant-btn { - display: block; - margin-left: auto; - margin-top: 12px; - } -} diff --git a/src/components/AuthorisedAction.tsx b/src/components/AuthorisedAction.tsx deleted file mode 100644 index fb15746..0000000 --- a/src/components/AuthorisedAction.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { FunctionComponent, useRef } from "react"; -import { Popover, Input, Button } from "antd"; -import { useTranslation } from "react-i18next"; - -import "./AuthorisedAction.less"; - -interface Props { - -} - -export const AuthorisedAction: FunctionComponent = ({ children }) => { - const { t } = useTranslation(); - const inputRef = useRef(null); - - return { - if (visible) setTimeout(() => { if (inputRef.current) inputRef.current.focus(); }, 20); - }} - content={<> -

{t("masterPassword.popoverDescription")}

- - {/* Fake username field to trick autofill */} - - - - - - } - > - {children} -
; -}; diff --git a/src/components/auth/AuthMasterPasswordPopover.tsx b/src/components/auth/AuthMasterPasswordPopover.tsx new file mode 100644 index 0000000..cf12ad6 --- /dev/null +++ b/src/components/auth/AuthMasterPasswordPopover.tsx @@ -0,0 +1,84 @@ +import { useState, useRef, FunctionComponent } from "react"; +import { Popover, Button, Input, Form } from "antd"; +import { useTranslation } from "react-i18next"; + +import { useDispatch, useSelector, shallowEqual } from "react-redux"; +import { RootState } from "../../store"; + +import { getMasterPasswordInput, FakeUsernameInput } from "./MasterPasswordInput"; + +import { authMasterPassword } from "../../krist/WalletManager"; + +interface FormValues { + masterPassword: string; +} + +interface Props { + onSubmit: () => void; +} + +export const AuthMasterPasswordPopover: FunctionComponent = ({ onSubmit, children }) => { + const { salt, tester } = useSelector((s: RootState) => s.walletManager, shallowEqual); + const dispatch = useDispatch(); + + const { t } = useTranslation(); + const [form] = Form.useForm(); + const [passwordError, setPasswordError] = useState(); + const inputRef = useRef(null); + + async function onFinish(values: FormValues) { + try { + await authMasterPassword(dispatch, salt, tester, values.masterPassword); + onSubmit(); + } catch (e) { + const message = e.message // Translate the error if we can + ? e.message.startsWith("masterPassword.") ? t(e.message) : e.message + : t("masterPassword.errorUnknown"); + + setPasswordError(message); + } + } + + return { + if (visible) setTimeout(() => { if (inputRef.current) inputRef.current.focus(); }, 20); + }} + content={<> +

{t("masterPassword.popoverDescription")}

+ +
+ + + {/* Password input */} + + {getMasterPasswordInput({ inputRef, placeholder: t("masterPassword.passwordPlaceholder"), autoFocus: true })} + + + + + } + > + {children} +
+} diff --git a/src/components/auth/AuthorisedAction.less b/src/components/auth/AuthorisedAction.less new file mode 100644 index 0000000..de85785 --- /dev/null +++ b/src/components/auth/AuthorisedAction.less @@ -0,0 +1,9 @@ +.authorised-action-popover { + width: 320px; + + .ant-btn { + display: block; + margin-left: auto; + margin-top: 12px; + } +} diff --git a/src/components/auth/AuthorisedAction.tsx b/src/components/auth/AuthorisedAction.tsx new file mode 100644 index 0000000..71c9c59 --- /dev/null +++ b/src/components/auth/AuthorisedAction.tsx @@ -0,0 +1,44 @@ +import { FunctionComponent, useState } from "react"; + +import { useSelector, shallowEqual } from "react-redux"; +import { RootState } from "../../store"; + +import { AuthMasterPasswordPopover } from "./AuthMasterPasswordPopover"; +import { SetMasterPasswordModal } from "./SetMasterPasswordModal"; + +import "./AuthorisedAction.less"; + +interface Props { + onAuthed?: () => void; +} + +export const AuthorisedAction: FunctionComponent = ({ onAuthed, children }) => { + const { isAuthed, hasMasterPassword } + = useSelector((s: RootState) => s.walletManager, shallowEqual); + + const [modalVisible, setModalVisible] = useState(false); + + if (isAuthed) { + // The user is authed with their master password, just perform the action + // directly: + return
{children}
+ } else if (!hasMasterPassword) { + // The user does not yet have a master password, prompt them to create one: + return <> +
setModalVisible(true)}>{children}
+ setModalVisible(false)} + onSubmit={() => { setModalVisible(false); if (onAuthed) onAuthed(); }} + /> + + } else { + // The user has a master password set but is not logged in, prompt them to + // enter it: + return { if (onAuthed) onAuthed(); }} + > + {children} + + } +}; diff --git a/src/components/auth/MasterPasswordInput.tsx b/src/components/auth/MasterPasswordInput.tsx new file mode 100644 index 0000000..d1e02b9 --- /dev/null +++ b/src/components/auth/MasterPasswordInput.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Input } from "antd"; + +interface Props { + inputRef?: React.Ref; + placeholder: string; + tabIndex?: number; + autoFocus?: boolean; +} + +/// Fake username field for master password inputs, to trick autofill. +export function FakeUsernameInput() { + return +} + +export function getMasterPasswordInput({ inputRef, placeholder, tabIndex, autoFocus }: Props) { + return +} diff --git a/src/components/auth/SetMasterPasswordModal.tsx b/src/components/auth/SetMasterPasswordModal.tsx new file mode 100644 index 0000000..a433a76 --- /dev/null +++ b/src/components/auth/SetMasterPasswordModal.tsx @@ -0,0 +1,92 @@ +import { useRef } from "react"; +import { Modal, Form, Input, Button } from "antd"; +import { useTranslation, Trans } from "react-i18next"; +import { useDispatch } from "react-redux"; + +import { getMasterPasswordInput, FakeUsernameInput } from "./MasterPasswordInput"; + +import { setMasterPassword } from "../../krist/WalletManager"; + +interface Props { + visible: boolean; + onCancel: () => void; + onSubmit: () => void; +} + +export function SetMasterPasswordModal({ visible, onCancel, onSubmit }: Props) { + const dispatch = useDispatch(); + + const { t } = useTranslation(); + const [form] = Form.useForm(); + const inputRef = useRef(null); + + async function onFinish() { + const values = await form.validateFields(); + form.resetFields(); + + await setMasterPassword(dispatch, values.masterPassword); + + onSubmit(); + } + + return { form.resetFields(); onCancel(); }} + onOk={onFinish} + > +

+ + Enter a 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. + +

+ +
+ + + {/* Password input */} + + {getMasterPasswordInput({ inputRef, placeholder: t("masterPassword.passwordPlaceholder"), autoFocus: true })} + + + {/* Password confirm input */} + ({ + validator(_, value) { + if (!value || getFieldValue("masterPassword") === value) + return Promise.resolve(); + return Promise.reject(t("masterPassword.errorPasswordInequal")); + } + }) + ]} + style={{ marginBottom: 0 }} + > + {getMasterPasswordInput({ placeholder: t("masterPassword.passwordConfirmPlaceholder"), tabIndex: 2 })} + + + {/* Ghost submit button to make 'enter' work */} + + +

+ + message.success("Something authed happened!")}> diff --git a/src/style/theme.less b/src/style/theme.less index f091b91..423e373 100644 --- a/src/style/theme.less +++ b/src/style/theme.less @@ -25,6 +25,7 @@ @text-color-secondary: @kw-text-secondary; @text-color-secondary-dark: @kw-text-secondary; @heading-color: @kw-text; +@error-color: @kw-red; @font-size-base: 16px; @font-size-sm: 13px;