diff --git a/doc/CONTRIBUTION.md b/doc/CONTRIBUTION.md index fe4d9afd7a..9be5f2a108 100644 --- a/doc/CONTRIBUTION.md +++ b/doc/CONTRIBUTION.md @@ -4,7 +4,7 @@ We welcome and appreciate new contributions. ### Testnet/testing-accounts for development use Alby testnet -We set up our own internal testnet, which can be used for your development. +We have setup some testnet nodes, which can be used for your development. If this is not reachable please let us know. - [Test-setup](https://github.com/getAlby/lightning-browser-extension/wiki/Test-setup) for different connectors (i.e. LND) diff --git a/package.json b/package.json index 6160854e46..ef96434c9a 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,9 @@ "@headlessui/react": "^1.7.14", "@lightninglabs/lnc-web": "^0.2.4-alpha", "@noble/secp256k1": "^1.7.1", + "@scure/bip32": "^1.3.0", + "@scure/bip39": "^1.2.0", + "@scure/btc-signer": "^0.5.1", "@tailwindcss/forms": "^0.5.3", "@vespaiach/axios-fetch-adapter": "^0.3.0", "axios": "^0.27.2", diff --git a/src/app/components/Alert/index.tsx b/src/app/components/Alert/index.tsx new file mode 100644 index 0000000000..cbf2f63309 --- /dev/null +++ b/src/app/components/Alert/index.tsx @@ -0,0 +1,22 @@ +import { classNames } from "~/app/utils"; + +type Props = { + type: "warn" | "info"; + children: React.ReactNode; +}; + +export default function Alert({ type, children }: Props) { + return ( +
+

{children}

+
+ ); +} diff --git a/src/app/components/Badge/index.tsx b/src/app/components/Badge/index.tsx index fffdf20015..d4e65ba52e 100644 --- a/src/app/components/Badge/index.tsx +++ b/src/app/components/Badge/index.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; type Props = { - label: "active" | "auth"; + label: "active" | "auth" | "imported"; color: string; textColor: string; small?: boolean; diff --git a/src/app/components/Button/index.tsx b/src/app/components/Button/index.tsx index cb887cce07..d11da4667f 100644 --- a/src/app/components/Button/index.tsx +++ b/src/app/components/Button/index.tsx @@ -9,6 +9,7 @@ export type Props = React.ButtonHTMLAttributes & { label: string; icon?: React.ReactNode; primary?: boolean; + error?: boolean; outline?: boolean; loading?: boolean; disabled?: boolean; @@ -30,13 +31,16 @@ const Button = forwardRef( primary = false, outline = false, loading = false, + error = false, flex = false, className, + ...otherProps }: Props, ref: Ref ) => { return ( + ); +} +export default InputCopyButton; diff --git a/src/app/components/PasswordForm/index.tsx b/src/app/components/PasswordForm/index.tsx index 64aad97fa8..84b241feb7 100644 --- a/src/app/components/PasswordForm/index.tsx +++ b/src/app/components/PasswordForm/index.tsx @@ -99,7 +99,6 @@ export default function PasswordForm< type={passwordView ? "text" : "password"} required onChange={handleChange} - tabIndex={0} minLength={minLength} pattern={minLength ? `.{${minLength},}` : undefined} title={ @@ -140,7 +139,6 @@ export default function PasswordForm< required onChange={handleChange} onBlur={validate} - tabIndex={1} endAdornment={ + } + /> + + + ); + })} + + {!readOnly && ( + + {wordlist.map((word) => ( + + )} + {children} + + ); +} diff --git a/src/app/components/mnemonic/SecretKeyDescription/index.tsx b/src/app/components/mnemonic/SecretKeyDescription/index.tsx new file mode 100644 index 0000000000..52eb004fbc --- /dev/null +++ b/src/app/components/mnemonic/SecretKeyDescription/index.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; +import NostrIcon from "~/app/icons/NostrIcon"; + +function SecretKeyDescription() { + const { t } = useTranslation("translation", { + keyPrefix: "accounts.account_view.mnemonic", + }); + + return ( + <> +

+ {t("backup.description1")} +

+
+ } + title={t("backup.protocols.nostr")} + /> +
+ +

+ {t("backup.description2")} +

+ + ); +} + +export default SecretKeyDescription; + +type ProtocolListItemProps = { icon: React.ReactNode; title: string }; + +function ProtocolListItem({ icon, title }: ProtocolListItemProps) { + return ( +
+ {icon} + {title} +
+ ); +} diff --git a/src/app/hooks/useTips.test.ts b/src/app/hooks/useTips.test.ts index e41bde7622..b8f184bfe0 100644 --- a/src/app/hooks/useTips.test.ts +++ b/src/app/hooks/useTips.test.ts @@ -34,14 +34,14 @@ describe("useTips", () => { test("should not have top up wallet tip when using a non-alby account", async () => { tmpAccount = { id: "1", name: "LND account", alias: "" }; const { tips } = useTips(); - expect(tips.length).toBe(1); + expect(tips.length).toBe(1); // mnemonic const hasTopUpWallet = tips.some((tip) => tip === TIPS.TOP_UP_WALLET); expect(hasTopUpWallet).toBe(false); }); test("should have top up wallet tip when having alby account", async () => { tmpAccount = { id: "2", name: "Alby", alias: "🐝 getalby.com" }; const { tips } = useTips(); - expect(tips.length).toBe(2); + expect(tips.length).toBe(2); // mnemonic + top up const hasTopUpWallet = tips.some((tip) => tip === TIPS.TOP_UP_WALLET); expect(hasTopUpWallet).toBe(true); }); diff --git a/src/app/icons/LiquidIcon.tsx b/src/app/icons/LiquidIcon.tsx new file mode 100644 index 0000000000..cf65dde751 --- /dev/null +++ b/src/app/icons/LiquidIcon.tsx @@ -0,0 +1,44 @@ +import { SVGProps } from "react"; + +const LiquidIcon = (props: SVGProps) => ( + + + + + + + +); +export default LiquidIcon; diff --git a/src/app/icons/NostrIcon.tsx b/src/app/icons/NostrIcon.tsx new file mode 100644 index 0000000000..0ec7e13cbf --- /dev/null +++ b/src/app/icons/NostrIcon.tsx @@ -0,0 +1,18 @@ +import { SVGProps } from "react"; + +const NostrIcon = (props: SVGProps) => ( + + + +); +export default NostrIcon; diff --git a/src/app/router/Options/Options.tsx b/src/app/router/Options/Options.tsx index e587a24700..2a174bb170 100644 --- a/src/app/router/Options/Options.tsx +++ b/src/app/router/Options/Options.tsx @@ -21,9 +21,14 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { HashRouter, Navigate, Outlet, Route, Routes } from "react-router-dom"; import { ToastContainer } from "react-toastify"; +import ScrollToTop from "~/app/components/ScrollToTop"; import Providers from "~/app/context/Providers"; import RequireAuth from "~/app/router/RequireAuth"; import { getConnectorRoutes, renderRoutes } from "~/app/router/connectorRoutes"; +import BackupSecretKey from "~/app/screens/Accounts/BackupSecretKey"; +import GenerateSecretKey from "~/app/screens/Accounts/GenerateSecretKey"; +import ImportSecretKey from "~/app/screens/Accounts/ImportSecretKey"; +import NostrSettings from "~/app/screens/Accounts/NostrSettings"; import Discover from "~/app/screens/Discover"; import OnChainReceive from "~/app/screens/OnChainReceive"; import AlbyWalletCreate from "~/app/screens/connectors/AlbyWallet/create"; @@ -46,6 +51,7 @@ function Options() { return ( + } /> } /> + } + /> + } + /> + } + /> + } /> } // prompt will always have an `origin` set, just the type is optional to support usage via PopUp /> + } // prompt will always have an `origin` set, just the type is optional to support usage via PopUp + /> } /> } /> } /> } /> + } /> } diff --git a/src/app/screens/Accounts/BackupSecretKey/index.tsx b/src/app/screens/Accounts/BackupSecretKey/index.tsx new file mode 100644 index 0000000000..e6938de31c --- /dev/null +++ b/src/app/screens/Accounts/BackupSecretKey/index.tsx @@ -0,0 +1,54 @@ +import Container from "@components/Container"; +import Loading from "@components/Loading"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; +import { toast } from "react-toastify"; +import { ContentBox } from "~/app/components/ContentBox"; +import MnemonicInputs from "~/app/components/mnemonic/MnemonicInputs"; +import SecretKeyDescription from "~/app/components/mnemonic/SecretKeyDescription"; +import api from "~/common/lib/api"; + +function BackupSecretKey() { + const [mnemonic, setMnemonic] = useState(); + const { t } = useTranslation("translation", { + keyPrefix: "accounts.account_view.mnemonic", + }); + + const { id } = useParams(); + + const fetchData = useCallback(async () => { + try { + const accountMnemonic = await api.getMnemonic(id as string); + setMnemonic(accountMnemonic); + } catch (e) { + console.error(e); + if (e instanceof Error) toast.error(`Error: ${e.message}`); + } + }, [id]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return !mnemonic ? ( +
+ +
+ ) : ( +
+ + +

+ {t("backup.title")} +

+ + + +
+
+
+ ); +} + +export default BackupSecretKey; diff --git a/src/app/screens/Accounts/Detail/index.tsx b/src/app/screens/Accounts/Detail/index.tsx index 08bcf7f762..c582e476d6 100644 --- a/src/app/screens/Accounts/Detail/index.tsx +++ b/src/app/screens/Accounts/Detail/index.tsx @@ -2,12 +2,7 @@ import { CaretLeftIcon, ExportIcon, } from "@bitcoin-design/bitcoin-icons-react/filled"; -import { - CopyIcon, - CrossIcon, - HiddenIcon, - VisibleIcon, -} from "@bitcoin-design/bitcoin-icons-react/outline"; +import { CrossIcon } from "@bitcoin-design/bitcoin-icons-react/outline"; import Button from "@components/Button"; import Container from "@components/Container"; import Header from "@components/Header"; @@ -19,22 +14,26 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import type { FormEvent } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Trans, useTranslation } from "react-i18next"; +import { useTranslation } from "react-i18next"; import Modal from "react-modal"; import QRCode from "react-qr-code"; -import { useNavigate, useParams } from "react-router-dom"; +import { Link, useNavigate, useParams } from "react-router-dom"; import { toast } from "react-toastify"; +import Alert from "~/app/components/Alert"; import Avatar from "~/app/components/Avatar"; +import Badge from "~/app/components/Badge"; +import InputCopyButton from "~/app/components/InputCopyButton"; +import MenuDivider from "~/app/components/Menu/MenuDivider"; +import Select from "~/app/components/form/Select"; import { useAccount } from "~/app/context/AccountContext"; import { useAccounts } from "~/app/context/AccountsContext"; import { useSettings } from "~/app/context/SettingsContext"; import api, { GetAccountRes } from "~/common/lib/api"; import msg from "~/common/lib/msg"; -import nostrlib from "~/common/lib/nostr"; -import Nostr from "~/extension/background-script/nostr"; -import type { Account } from "~/types"; +import nostr from "~/common/lib/nostr"; +import type { Account, BitcoinNetworkType } from "~/types"; -type AccountAction = Omit; +type AccountAction = Pick; dayjs.extend(relativeTime); function AccountDetail() { @@ -59,40 +58,31 @@ function AccountDetail() { }); const [accountName, setAccountName] = useState(""); - const [currentPrivateKey, setCurrentPrivateKey] = useState(""); - const [nostrPrivateKey, setNostrPrivateKey] = useState(""); + const [hasMnemonic, setHasMnemonic] = useState(false); const [nostrPublicKey, setNostrPublicKey] = useState(""); - const [nostrPrivateKeyVisible, setNostrPrivateKeyVisible] = useState(false); - const [privateKeyCopyLabel, setPrivateKeyCopyLabel] = useState( - tCommon("actions.copy") as string - ); - const [publicKeyCopyLabel, setPublicKeyCopyLabel] = useState( - tCommon("actions.copy") as string - ); + const [hasImportedNostrKey, setHasImportedNostrKey] = useState(false); const [exportLoading, setExportLoading] = useState(false); const [exportModalIsOpen, setExportModalIsOpen] = useState(false); - const [nostrKeyModalIsOpen, setNostrKeyModalIsOpen] = useState(false); const fetchData = useCallback(async () => { try { if (id) { - const response = await msg.request("getAccount", { - id, - }); - // for backwards compatibility - // TODO: remove. if you ask when, then it's probably now. - if (!response.id) { - response.id = id; - } + const response = await api.getAccount(id); setAccount(response); setAccountName(response.name); - - const priv = (await msg.request("nostr/getPrivateKey", { - id, - })) as string; - if (priv) { - setCurrentPrivateKey(priv); + setHasMnemonic(response.hasMnemonic); + setHasImportedNostrKey(response.hasImportedNostrKey); + + if (response.nostrEnabled) { + const nostrPublicKeyHex = await api.nostr.getPublicKey(id); + if (nostrPublicKeyHex) { + const nostrPublicKeyNpub = nostr.hexToNip19( + nostrPublicKeyHex, + "npub" + ); + setNostrPublicKey(nostrPublicKeyNpub); + } } } } catch (e) { @@ -105,82 +95,6 @@ function AccountDetail() { setExportModalIsOpen(false); } - function closeNostrKeyModal() { - setNostrKeyModalIsOpen(false); - } - - function generatePublicKey(priv: string) { - const nostr = new Nostr(priv); - const pubkeyHex = nostr.getPublicKey(); - return nostrlib.hexToNip19(pubkeyHex, "npub"); - } - - async function generateNostrPrivateKey(random?: boolean) { - const selectedAccount = await auth.fetchAccountInfo(); - - if (!random && selectedAccount?.id !== id) { - alert( - `Please match the account in the account dropdown at the top with this account to derive keys.` - ); - closeNostrKeyModal(); - return; - } - // check with current selected account - const result = await msg.request( - "nostr/generatePrivateKey", - random - ? { - type: "random", - } - : undefined - ); - saveNostrPrivateKey(result.privateKey as string); - closeNostrKeyModal(); - } - - async function saveNostrPrivateKey(nostrPrivateKey: string) { - nostrPrivateKey = nostrlib.normalizeToHex(nostrPrivateKey); - - if (nostrPrivateKey === currentPrivateKey) return; - - if ( - currentPrivateKey && - prompt(t("nostr.private_key.warning"))?.toLowerCase() !== - account?.name?.toLowerCase() - ) { - toast.error(t("nostr.private_key.failed_to_remove")); - return; - } - - try { - if (!account) { - // type guard - throw new Error("No account available"); - } - - if (nostrPrivateKey) { - // Validate the private key before saving - generatePublicKey(nostrPrivateKey); - nostrlib.hexToNip19(nostrPrivateKey, "nsec"); - - await msg.request("nostr/setPrivateKey", { - id: account.id, - privateKey: nostrPrivateKey, - }); - toast.success(t("nostr.private_key.success")); - } else { - await msg.request("nostr/removePrivateKey", { - id: account.id, - }); - toast.success(t("nostr.private_key.successfully_removed")); - } - - setCurrentPrivateKey(nostrPrivateKey); - } catch (e) { - if (e instanceof Error) toast.error(e.message); - } - } - async function updateAccountName({ id, name }: AccountAction) { await msg.request("editAccount", { name, @@ -210,7 +124,10 @@ function AccountDetail() { } async function removeAccount({ id, name }: AccountAction) { - if (window.confirm(t("remove.confirm", { name }))) { + if ( + window.prompt(t("remove.confirm", { name }))?.toLowerCase() == + accountName.toLowerCase() + ) { let nextAccountId; let accountIds = Object.keys(accounts); if (auth.account?.id === id && accountIds.length > 1) { @@ -227,6 +144,22 @@ function AccountDetail() { } else { window.close(); } + } else { + toast.error(t("remove.error")); + } + } + async function removeMnemonic({ id, name }: AccountAction) { + if ( + window.prompt(t("remove_secretkey.confirm", { name }))?.toLowerCase() == + accountName.toLowerCase() + ) { + // TODO: consider adding removeMnemonic function + await api.setMnemonic(id, null); + setHasMnemonic(false); + setHasImportedNostrKey(true); + toast.success(t("remove_secretkey.success")); + } else { + toast.error(t("remove.error")); } } @@ -238,26 +171,6 @@ function AccountDetail() { } }, [fetchData, isLoadingSettings]); - useEffect(() => { - try { - setNostrPublicKey( - currentPrivateKey ? generatePublicKey(currentPrivateKey) : "" - ); - setNostrPrivateKey( - currentPrivateKey ? nostrlib.hexToNip19(currentPrivateKey, "nsec") : "" - ); - } catch (e) { - if (e instanceof Error) - toast.error( -

- {t("nostr.errors.failed_to_load")} -
- {e.message} -

- ); - } - }, [currentPrivateKey, t]); - return !account ? (
@@ -396,6 +309,7 @@ function AccountDetail() { { @@ -415,152 +329,138 @@ function AccountDetail() {
+

- {t("nostr.title")} + {t("mnemonic.title")}

- - {t("nostr.title")} - {" "} - {t("nostr.hint")} + {t("mnemonic.description")}

-
-
-
- - {t("nostr.private_key.title")} - -

- , - ]} - /> + +

+ {hasMnemonic && ( + {t("mnemonic.backup.warning")} + )} + +
+
+

+ {t( + hasMnemonic + ? "mnemonic.backup.title" + : "mnemonic.generate.title" + )} +

+

+ {t("mnemonic.description2")}

-
-
-
-
- {t("nostr.private_key.backup")} -
-
{ - e.preventDefault(); - saveNostrPrivateKey(nostrPrivateKey); - }} - className="mb-4 flex justify-between items-end" - > -
- { - setNostrPrivateKey(event.target.value.trim()); - }} - endAdornment={ - - } - /> -
-
-
+
-
-
+
+ {!hasMnemonic && ( + <> + +
+
+

+ {t("mnemonic.import.title")} +

+

+ {t("mnemonic.import.description")} +

+
-
-
+
+ +
+
+ + )} + +
+
+ } /> + {nostrPublicKey && hasImportedNostrKey && ( + + )}
-
-
+
+ +
+
+

+ {t("bitcoin.network.title")} +

+

+ {t("bitcoin.network.subtitle")} +

+
+ +
+
-
@@ -572,6 +472,25 @@ function AccountDetail() {
+ {hasMnemonic && ( + +
+
+
+ )}
- - -
-

- {t("nostr.generate_keys.title")} -

- -
-
- , - ]} - /> -
-
-
-
-
-
diff --git a/src/app/screens/Accounts/GenerateSecretKey/index.tsx b/src/app/screens/Accounts/GenerateSecretKey/index.tsx new file mode 100644 index 0000000000..05a3d4ad08 --- /dev/null +++ b/src/app/screens/Accounts/GenerateSecretKey/index.tsx @@ -0,0 +1,113 @@ +import Container from "@components/Container"; +import Loading from "@components/Loading"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useParams } from "react-router-dom"; +import { toast } from "react-toastify"; +import Alert from "~/app/components/Alert"; +import Button from "~/app/components/Button"; +import { ContentBox } from "~/app/components/ContentBox"; +import Checkbox from "~/app/components/form/Checkbox"; +import MnemonicInputs from "~/app/components/mnemonic/MnemonicInputs"; +import SecretKeyDescription from "~/app/components/mnemonic/SecretKeyDescription"; +import api from "~/common/lib/api"; + +function GenerateSecretKey() { + const navigate = useNavigate(); + const [mnemonic, setMnemonic] = useState(); + const { t } = useTranslation("translation", { + keyPrefix: "accounts.account_view.mnemonic", + }); + const [hasConfirmedBackup, setHasConfirmedBackup] = useState(false); + useState(false); + const [hasNostrPrivateKey, setHasNostrPrivateKey] = useState(false); + + const { id } = useParams() as { id: string }; + + useEffect(() => { + (async () => { + try { + const account = await api.getAccount(id); + setHasNostrPrivateKey(account.nostrEnabled); + if (account.hasMnemonic) { + // do not allow user to generate a mnemonic if they already have one for the current account + // go to account settings + navigate(`/accounts/${id}`); + } + const newMnemonic = await api.generateMnemonic(); + setMnemonic(newMnemonic); + } catch (e) { + console.error(e); + if (e instanceof Error) toast.error(`Error: ${e.message}`); + } + })(); + }, [id, navigate]); + + async function saveGeneratedSecretKey() { + try { + if (!hasConfirmedBackup) { + throw new Error(t("backup.error_confirm")); + } + if (!mnemonic) { + throw new Error("No mnemonic available"); + } + + await api.setMnemonic(id, mnemonic); + + toast.success(t("saved")); + // go to account settings + navigate(`/accounts/${id}`); + } catch (e) { + if (e instanceof Error) toast.error(e.message); + } + } + + return !mnemonic ? ( +
+ +
+ ) : ( +
+ + +

+ {t("generate.title")} +

+ + + <> +
+ { + setHasConfirmedBackup(event.target.checked); + }} + /> + +
+ +
+ {hasNostrPrivateKey && ( + {t("existing_nostr_key_notice")} + )} +
+
+
+
+
+ ); +} + +export default GenerateSecretKey; diff --git a/src/app/screens/Accounts/ImportSecretKey/index.tsx b/src/app/screens/Accounts/ImportSecretKey/index.tsx new file mode 100644 index 0000000000..77f16e7325 --- /dev/null +++ b/src/app/screens/Accounts/ImportSecretKey/index.tsx @@ -0,0 +1,98 @@ +import Container from "@components/Container"; +import Loading from "@components/Loading"; +import * as bip39 from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useParams } from "react-router-dom"; +import { toast } from "react-toastify"; +import Alert from "~/app/components/Alert"; +import Button from "~/app/components/Button"; +import { ContentBox } from "~/app/components/ContentBox"; +import MnemonicInputs from "~/app/components/mnemonic/MnemonicInputs"; +import api from "~/common/lib/api"; + +function ImportSecretKey() { + const [mnemonic, setMnemonic] = useState(""); + const { t: tCommon } = useTranslation("common"); + const navigate = useNavigate(); + const { t } = useTranslation("translation", { + keyPrefix: "accounts.account_view.mnemonic", + }); + + const [hasFetchedData, setHasFetchedData] = useState(false); + const [hasNostrPrivateKey, setHasNostrPrivateKey] = useState(false); + const { id } = useParams() as { id: string }; + + useEffect(() => { + (async () => { + try { + const account = await api.getAccount(id); + setHasNostrPrivateKey(account.nostrEnabled); + if (account.hasMnemonic) { + // do not allow user to import a mnemonic if they already have one for the current account + // go to account settings + navigate(`/accounts/${id}`); + } + setHasFetchedData(true); + } catch (e) { + console.error(e); + if (e instanceof Error) toast.error(`Error: ${e.message}`); + } + })(); + }, [id, navigate]); + + function cancelImport() { + // go to account settings + navigate(`/accounts/${id}`); + } + + async function importKey() { + try { + if ( + mnemonic.split(" ").length !== 12 || + !bip39.validateMnemonic(mnemonic, wordlist) + ) { + throw new Error("Invalid mnemonic"); + } + + await api.setMnemonic(id, mnemonic); + toast.success(t("saved")); + // go to account settings + navigate(`/accounts/${id}`); + } catch (e) { + if (e instanceof Error) toast.error(e.message); + } + } + + return !hasFetchedData ? ( +
+ +
+ ) : ( +
+ + +

+ {t("import.title")} +

+

+ {t("import.description")} +

+ + + {hasNostrPrivateKey && ( + {t("existing_nostr_key_notice")} + )} +
+ +
+
+
+
+ ); +} + +export default ImportSecretKey; diff --git a/src/app/screens/Accounts/NostrSettings/index.tsx b/src/app/screens/Accounts/NostrSettings/index.tsx new file mode 100644 index 0000000000..a6127a30c1 --- /dev/null +++ b/src/app/screens/Accounts/NostrSettings/index.tsx @@ -0,0 +1,267 @@ +import { + HiddenIcon, + VisibleIcon, +} from "@bitcoin-design/bitcoin-icons-react/filled"; +import Container from "@components/Container"; +import Loading from "@components/Loading"; +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { toast } from "react-toastify"; +import Alert from "~/app/components/Alert"; +import Avatar from "~/app/components/Avatar"; +import Button from "~/app/components/Button"; +import { ContentBox } from "~/app/components/ContentBox"; +import InputCopyButton from "~/app/components/InputCopyButton"; +import TextField from "~/app/components/form/TextField"; +import api, { GetAccountRes } from "~/common/lib/api"; +import { default as nostr, default as nostrlib } from "~/common/lib/nostr"; + +function NostrSettings() { + const { t: tCommon } = useTranslation("common"); + const { t } = useTranslation("translation", { + keyPrefix: "accounts.account_view", + }); + const navigate = useNavigate(); + const [hasMnemonic, setHasMnemonic] = useState(false); + const [currentPrivateKey, setCurrentPrivateKey] = useState(""); + const [nostrPrivateKey, setNostrPrivateKey] = useState(""); + const [nostrPrivateKeyVisible, setNostrPrivateKeyVisible] = useState(false); + const [nostrPublicKey, setNostrPublicKey] = useState(""); + const [hasImportedNostrKey, setHasImportedNostrKey] = useState(false); + const [account, setAccount] = useState(); + const { id } = useParams() as { id: string }; + + const fetchData = useCallback(async () => { + if (id) { + const priv = await api.nostr.getPrivateKey(id); + if (priv) { + setCurrentPrivateKey(priv); + setNostrPrivateKey(priv); + } + const accountResponse = await api.getAccount(id); + setHasMnemonic(accountResponse.hasMnemonic); + setHasImportedNostrKey(accountResponse.hasImportedNostrKey); + setAccount(accountResponse); + } + }, [id]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + useEffect(() => { + try { + // TODO: is there a way this can be moved to the background script and use the Nostr object? + // NOTE: it is done this way to show the user the new public key before saving + setNostrPublicKey( + nostrPrivateKey + ? nostr.derivePublicKey(nostr.normalizeToHex(nostrPrivateKey)) + : "" + ); + } catch (e) { + if (e instanceof Error) + toast.error( +

+ {t("nostr.errors.failed_to_load")} +
+ {e.message} +

+ ); + } + }, [nostrPrivateKey, t]); + + function onCancel() { + // go to account settings + navigate(`/accounts/${id}`); + } + + function handleDeleteKeys() { + setNostrPrivateKey(""); + } + + async function handleDeriveNostrKeyFromSecretKey() { + if (!hasMnemonic) { + throw new Error("No mnemonic exists"); + } + + const derivedNostrPrivateKey = await api.nostr.generatePrivateKey(id); + setNostrPrivateKey(derivedNostrPrivateKey); + } + + // TODO: simplify this method - would be good to have a dedicated "remove nostr key" button + async function handleSaveNostrPrivateKey() { + if (nostrPrivateKey === currentPrivateKey) { + throw new Error("private key hasn't changed"); + } + + if ( + currentPrivateKey && + prompt(t("nostr.private_key.warning"))?.toLowerCase() !== + account?.name?.toLowerCase() + ) { + toast.error(t("nostr.private_key.failed_to_remove")); + return; + } + + try { + if (nostrPrivateKey) { + await api.nostr.setPrivateKey(id, nostrPrivateKey); + } else { + await api.nostr.removePrivateKey(id); + } + + toast.success( + t( + nostrPrivateKey + ? "nostr.private_key.success" + : "nostr.private_key.successfully_removed" + ) + ); + } catch (e) { + console.error(e); + if (e instanceof Error) { + toast.error(e.message); + } + } + // go to account settings + navigate(`/accounts/${id}`); + } + + return !account ? ( +
+ +
+ ) : ( +
+
{ + e.preventDefault(); + handleSaveNostrPrivateKey(); + }} + > + + +
+

+ {t("nostr.settings.title")} +

+
+ +

+ {account.name} +

+
+

+ {t("nostr.settings.description")} +

+
+ + {hasMnemonic && + currentPrivateKey && + nostrPrivateKey === currentPrivateKey ? ( + hasImportedNostrKey ? ( + + {t("nostr.settings.imported_key_warning")} + + ) : ( + {t("nostr.settings.can_restore")} + ) + ) : null} + +
+ { + setNostrPrivateKey(event.target.value.trim()); + }} + endAdornment={ +
+ + +
+ } + /> +
+ +
+ } + /> +
+ {nostrPrivateKey && ( +
+
+
+
+
+
+
+
+ ); +} + +export default NostrSettings; diff --git a/src/app/screens/ConfirmGetAddress/index.test.tsx b/src/app/screens/ConfirmGetAddress/index.test.tsx new file mode 100644 index 0000000000..53bb52087d --- /dev/null +++ b/src/app/screens/ConfirmGetAddress/index.test.tsx @@ -0,0 +1,56 @@ +import { act, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import type { OriginData } from "~/types"; + +import ConfirmGetAddress from "./index"; + +const mockOrigin: OriginData = { + location: "https://getalby.com/demo", + domain: "https://getalby.com", + host: "getalby.com", + pathname: "/demo", + name: "Alby", + description: "", + icon: "https://getalby.com/assets/alby-503261fa1b83c396b7ba8d927db7072d15fea5a84d387a654c5d0a2cefd44604.svg", + metaData: { + title: "Alby Demo", + url: "https://getalby.com/demo", + provider: "Alby", + image: + "https://getalby.com/assets/alby-503261fa1b83c396b7ba8d927db7072d15fea5a84d387a654c5d0a2cefd44604.svg", + icon: "https://getalby.com/favicon.ico", + }, + external: true, +}; + +jest.mock("~/app/hooks/useNavigationState", () => { + return { + useNavigationState: jest.fn(() => ({ + origin: mockOrigin, + args: { + index: 0, + num: 1, + change: false, + }, + })), + }; +}); + +describe("ConfirmGetAddress", () => { + test("render", async () => { + await act(async () => { + render( + + + + ); + }); + + expect( + await screen.findByText("This website asks you to read:") + ).toBeInTheDocument(); + expect( + await screen.findByText("Your Bitcoin receive address") + ).toBeInTheDocument(); + }); +}); diff --git a/src/app/screens/ConfirmGetAddress/index.tsx b/src/app/screens/ConfirmGetAddress/index.tsx new file mode 100644 index 0000000000..9200667062 --- /dev/null +++ b/src/app/screens/ConfirmGetAddress/index.tsx @@ -0,0 +1,93 @@ +//import Checkbox from "../../components/Form/Checkbox"; +import ConfirmOrCancel from "@components/ConfirmOrCancel"; +import Container from "@components/Container"; +import ContentMessage from "@components/ContentMessage"; +import PublisherCard from "@components/PublisherCard"; +import SuccessMessage from "@components/SuccessMessage"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { toast } from "react-toastify"; +import ScreenHeader from "~/app/components/ScreenHeader"; +import { useNavigationState } from "~/app/hooks/useNavigationState"; +import { USER_REJECTED_ERROR } from "~/common/constants"; +import msg from "~/common/lib/msg"; +import type { OriginData } from "~/types"; + +// TODO: rename this function to be specific to WebBTC +function ConfirmGetAddress() { + const navState = useNavigationState(); + const { t: tCommon } = useTranslation("common"); + const { t } = useTranslation("translation", { + keyPrefix: "confirm_get_address", + }); + const navigate = useNavigate(); + + const origin = navState.origin as OriginData; + const [loading, setLoading] = useState(false); + const [successMessage, setSuccessMessage] = useState(""); + + async function confirm() { + try { + setLoading(true); + const response = await msg.request("getAddress", {}, { origin }); + msg.reply(response); + setSuccessMessage(tCommon("success")); + } catch (e) { + console.error(e); + if (e instanceof Error) toast.error(`${tCommon("error")}: ${e.message}`); + } finally { + setLoading(false); + } + } + + function reject(e: React.MouseEvent) { + e.preventDefault(); + msg.error(USER_REJECTED_ERROR); + } + + function close(e: React.MouseEvent) { + if (navState.isPrompt) { + window.close(); + } else { + e.preventDefault(); + navigate(-1); + } + } + + return ( +
+ + {!successMessage ? ( + +
+ + + +
+ +
+ ) : ( + + + + + )} +
+ ); +} + +export default ConfirmGetAddress; diff --git a/src/app/screens/Settings/index.tsx b/src/app/screens/Settings/index.tsx index 2d37891cab..44f3ffa4fa 100644 --- a/src/app/screens/Settings/index.tsx +++ b/src/app/screens/Settings/index.tsx @@ -368,7 +368,6 @@ function Settings() {
-
diff --git a/src/app/screens/connectors/ChooseConnectorPath/index.tsx b/src/app/screens/connectors/ChooseConnectorPath/index.tsx index fa1a674ec7..de9d19a39f 100644 --- a/src/app/screens/connectors/ChooseConnectorPath/index.tsx +++ b/src/app/screens/connectors/ChooseConnectorPath/index.tsx @@ -29,10 +29,20 @@ export default function ChooseConnectorPath() { actions={ <> -