Newer
Older
CrypticOreWallet / src / pages / wallets / AddWalletModal.tsx
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Modal, Form, Input, Checkbox, Collapse, Button, Tooltip, Typography, Row, Col, message, notification, Grid } from "antd";
import { ReloadOutlined } from "@ant-design/icons";

import { useTranslation, Trans } from "react-i18next";

import { generatePassword } from "@utils";
import { useAddressPrefix } from "@utils/currency";

import { FakeUsernameInput } from "@comp/auth/FakeUsernameInput";
import { CopyInputButton } from "@comp/CopyInputButton";
import { SelectWalletCategory } from "@comp/wallets/SelectWalletCategory";

import { SelectWalletFormat } from "@comp/wallets/SelectWalletFormat";
import {
  Wallet, WalletFormatName, calculateAddress, formatNeedsUsername,
  useWallets, addWallet, decryptWallet, editWallet, ADDRESS_LIST_LIMIT,
  useMasterPasswordOnly
} from "@wallets";

import Debug from "debug";
const debug = Debug("kristweb:add-wallet-modal");

const { Text } = Typography;
const { useBreakpoint } = Grid;

interface FormValues {
  label?: string;
  category: string;

  walletUsername: string;
  password: string;
  format: WalletFormatName;

  save: boolean;
}

interface Props {
  create?: boolean;
  editing?: Wallet;

  visible: boolean;
  setVisible: React.Dispatch<React.SetStateAction<boolean>>;
  setAddExistingVisible?: React.Dispatch<React.SetStateAction<boolean>>;
}

export function AddWalletModal({ create, editing, visible, setVisible, setAddExistingVisible }: Props): JSX.Element {
  if (editing && create)
    throw new Error("AddWalletModal: 'editing' and 'create' simultaneously, uh oh!");

  const initialFormat = editing?.format || "kristwallet";

  // Required to encrypt new wallets
  const masterPassword = useMasterPasswordOnly();
  // Required to check for existing wallets
  const { wallets } = useWallets();
  const addressPrefix = useAddressPrefix();

  const { t } = useTranslation();
  const bps = useBreakpoint();

  const [form] = Form.useForm<FormValues>();
  const passwordInput = useRef<Input>(null);
  const [calculatedAddress, setCalculatedAddress] = useState<string | undefined>();
  const [formatState, setFormatState] = useState<WalletFormatName>(initialFormat);

  function closeModal() {
    form.resetFields(); // Make sure to generate another password on re-open
    setCalculatedAddress(undefined);
    setVisible(false);
  }

  function addExistingWallet() {
    if (!setAddExistingVisible) return;
    setAddExistingVisible(true);
    closeModal();
  }

  async function onSubmit() {
    if (!masterPassword) return notification.error({
      message: t("addWallet.errorUnexpectedTitle"),
      description: t("masterPassword.errorNoPassword")
    });

    const values = await form.validateFields();
    if (!values.password) return;

    try {
      if (editing) { // Edit wallet
        // Double check the destination wallet exists
        if (!wallets[editing.id]) return notification.error({
          message: t("addWallet.errorMissingWalletTitle"),
          description: t("addWallet.errorMissingWalletDescription")
        });

        // If the address changed, check that a wallet doesn't already exist
        // with this address
        if (editing.address !== calculatedAddress
          && Object.values(wallets).find(w => w.address === calculatedAddress)) {
          return notification.error({
            message: t("addWallet.errorDuplicateWalletTitle"),
            description: t("addWallet.errorDuplicateWalletDescription")
          });
        }

        await editWallet(addressPrefix, masterPassword, editing, values, values.password);
        message.success(t("addWallet.messageSuccessEdit"));

        closeModal();
      } else { // Add/create wallet
        // Check if we reached the wallet limit
        if (Object.keys(wallets).length >= ADDRESS_LIST_LIMIT) {
          return notification.error({
            message: t("addWallet.errorWalletLimitTitle"),
            description: t("addWallet.errorWalletLimitDescription")
          });
        }

        // Check if the wallet already exists
        if (Object.values(wallets).find(w => w.address === calculatedAddress)) {
          return notification.error({
            message: t("addWallet.errorDuplicateWalletTitle"),
            description: t("addWallet.errorDuplicateWalletDescription")
          });
        }

        await addWallet(addressPrefix, masterPassword, values, values.password, values.save ?? true);
        message.success(create ? t("addWallet.messageSuccessCreate") : t("addWallet.messageSuccessAdd"));

        closeModal();
      }
    } catch (err) {
      console.error(err);
      notification.error({
        message: t("addWallet.errorUnexpectedTitle"),
        description: editing
          ? t("addWallet.errorUnexpectedEditDescription")
          : t("addWallet.errorUnexpectedDescription")
      });
    }
  }

  function onValuesChange(changed: Partial<FormValues>, values: Partial<FormValues>) {
    if (changed.format) setFormatState(changed.format);

    if ((changed.format || changed.password || changed.walletUsername) && values.password)
      updateCalculatedAddress(values.format, values.password, values.walletUsername);
  }

  /** Update the 'Wallet address' field */
  const updateCalculatedAddress = useCallback(async function(format: WalletFormatName | undefined, password: string, username?: string) {
    const { address } = await calculateAddress(addressPrefix, format, password, username);
    setCalculatedAddress(address);
  }, [addressPrefix]);

  const generateNewPassword = useCallback(function() {
    if (!create || !form) return;
    const password = generatePassword();
    form.setFieldsValue({ password });
    updateCalculatedAddress("kristwallet", password);
  }, [create, form, updateCalculatedAddress]);

  useEffect(() => {
    if (visible && form && !form.getFieldValue("password")) {
      // Generate a password when the 'Create wallet' modal is opened
      if (create) generateNewPassword();

      // Populate the password when the 'Edit wallet' modal is opened
      if (editing && masterPassword) {
        (async () => {
          const dec = await decryptWallet(masterPassword, editing);
          if (!dec) return notification.error({
            message: t("addWallet.errorDecryptTitle"),
            description: t("addWallet.errorDecryptDescription")
          });

          const password = dec.password;
          form.setFieldsValue({ password });
          updateCalculatedAddress(form.getFieldValue("format"), password);
        })();
      }
    }
  }, [t, generateNewPassword, updateCalculatedAddress, masterPassword, visible, form, create, editing]);

  const initialValues = useMemo(() => ({
    label: editing?.label ?? undefined,
    category: editing?.category ?? "",

    username: editing?.username ?? undefined,

    format: editing?.format ?? initialFormat,
    save: true
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }), [
    initialFormat,
    editing?.id,
    editing?.label,
    editing?.category,
    editing?.username,
    editing?.format
  ]);

  // If the `editing` wallet ID changes, refresh the form
  useEffect(() => {
    if (!form || !editing?.id) return;
    form.setFieldsValue(initialValues);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [form, editing?.id]);

  return <Modal
    visible={visible}

    title={t(editing
      ? "addWallet.dialogTitleEdit"
      : (create ? "addWallet.dialogTitleCreate" : "addWallet.dialogTitle"))}

    footer={[
      /* Add existing wallet button */
      create && bps.sm && (
        <Button key="addExisting" onClick={addExistingWallet} style={{ float: "left" }}>
          {t("addWallet.dialogAddExisting")}
        </Button>
      ),

      /* Cancel button */
      <Button key="cancel" onClick={closeModal}>
        {t("dialog.cancel")}
      </Button>,

      /* OK button */
      <Button key="ok" type="primary" onClick={onSubmit}>
        {t(editing
          ? "addWallet.dialogOkEdit"
          : (create ? "addWallet.dialogOkCreate" : "addWallet.dialogOkAdd"))}
      </Button>,
    ]}

    onCancel={closeModal}
    destroyOnClose
  >
    <Form
      form={form}
      layout="vertical"

      name={editing
        ? "editWalletForm"
        : (create ? "createWalletForm" : "addWalletForm")}

      initialValues={initialValues}

      onValuesChange={onValuesChange}
    >
      <Row gutter={[24, 0]}>
        {/* Wallet label */}
        <Col span={12}>
          <Form.Item
            name="label"
            label={t("addWallet.walletLabel")}
            rules={[
              { max: 32, message: t("addWallet.walletLabelMaxLengthError") },
              { whitespace: true, message: t("addWallet.walletLabelWhitespaceError") }
            ]}
          >
            <Input
              placeholder={t("addWallet.walletLabelPlaceholder")}
              maxLength={32}
            />
          </Form.Item>
        </Col>

        {/* Wallet category */}
        <Col span={12}>
          <Form.Item name="category" label={t("addWallet.walletCategory")}>
            <SelectWalletCategory
              onNewCategory={category => form.setFieldsValue({ category })}
            />
          </Form.Item>
        </Col>
      </Row>

      {/* Fake username input to trick browser autofill */}
      <FakeUsernameInput />

      {/* Wallet username, if applicable */}
      {formatState && formatNeedsUsername(formatState) && (
        <Form.Item name="walletUsername" label={t("addWallet.walletUsername")}>
          <Input type="text" autoComplete="off" placeholder={t("addWallet.walletUsernamePlaceholder")}/>
        </Form.Item>
      )}

      {/* Wallet password */}
      <Form.Item
        label={formatState === "api"
          ? t("addWallet.walletPrivatekey")
          : t("addWallet.walletPassword")}
        style={{ marginBottom: 0 }}
      >
        <Input.Group compact style={{ display: "flex" }}>
          <Form.Item
            name="password"
            style={{ flex: 1, marginBottom: 0 }}
            rules={[
              {
                required: true,
                message: formatState === "api"
                  ? t("addWallet.errorPrivatekeyRequired")
                  : t("addWallet.errorPasswordRequired")
              }
            ]}
          >
            <Input
              ref={passwordInput}
              type={create ? "text" : "password"}
              readOnly={!!create}
              autoComplete="off"

              className={create ? "input-monospace" : ""}
              style={{ height: 32 }}

              placeholder={formatState === "api"
                ? t("addWallet.walletPrivatekeyPlaceholder")
                : t("addWallet.walletPasswordPlaceholder")}
            />
          </Form.Item>

          {create && <>
            <CopyInputButton targetInput={passwordInput} />

            <Tooltip title={t("addWallet.walletPasswordRegenerate")}>
              <Button icon={<ReloadOutlined />} onClick={generateNewPassword} />
            </Tooltip>
          </>}
        </Input.Group>
      </Form.Item>
      {/* Password save warning */}
      {create && <Text className="text-small" type="danger"><Trans t={t} i18nKey="addWallet.walletPasswordWarning">
        Make sure to save this somewhere <b>secure</b>!
      </Trans></Text>}

      {/* Calculated address */}
      <Form.Item label={t("addWallet.walletAddress")} style={{ marginTop: 24, marginBottom: 0 }}>
        <Input type="text" readOnly value={calculatedAddress} />
      </Form.Item>

      {/* Advanced options */}
      {!create && <Collapse ghost className="flush-collapse" style={{ marginTop: 24 }}>
        <Collapse.Panel forceRender header={t("addWallet.advancedOptions")} key="1">
          {/* Wallet format */}
          <Form.Item name="format" label={t("addWallet.walletFormat")}>
            {SelectWalletFormat({ initialFormat })}
          </Form.Item>

          {/* Save in KristWeb checkbox */}
          {!editing && <Form.Item name="save" valuePropName="checked" style={{ marginBottom: 0 }}>
            <Checkbox>{t("addWallet.walletSave")}</Checkbox>
          </Form.Item>}
        </Collapse.Panel>
      </Collapse>}
    </Form>
  </Modal>;
}