-
- {t("nostr.private_key.title")}
-
-
- ,
- ]}
- />
+
+
+ {hasMnemonic && (
+
{t("mnemonic.backup.warning")}
+ )}
+
+
+
+
+ {t(
+ hasMnemonic
+ ? "mnemonic.backup.title"
+ : "mnemonic.generate.title"
+ )}
+
+
+ {t("mnemonic.description2")}
-
- setNostrKeyModalIsOpen(true)}
- fullWidth
- />
-
-
-
- {t("nostr.private_key.backup")}
-
-
+
+ {!hasMnemonic && (
+ <>
+
+
+
+
+ {t("mnemonic.import.title")}
+
+
+ {t("mnemonic.import.description")}
+
+
-
-
+ >
+ )}
+
+
+
+ }
/>
+ {nostrPublicKey && hasImportedNostrKey && (
+
+ )}
-
-
}
- label={publicKeyCopyLabel}
- onClick={async () => {
- try {
- navigator.clipboard.writeText(nostrPublicKey);
- setPublicKeyCopyLabel(tCommon("copied"));
- setTimeout(() => {
- setPublicKeyCopyLabel(tCommon("actions.copy"));
- }, 1000);
- } catch (e) {
- if (e instanceof Error) {
- toast.error(e.message);
- }
- }
+
+
+
+
+
+
+
+
+
+
+
+ {t("bitcoin.network.title")}
+
+
+ {t("bitcoin.network.subtitle")}
+
+
+
+
+ {
+ // update local value
+ setAccount({
+ ...account,
+ bitcoinNetwork: event.target.value as BitcoinNetworkType,
+ });
+ await msg.request("editAccount", {
+ id,
+ bitcoinNetwork: event.target.value,
+ });
}}
- fullWidth
- />
+ >
+
+ {t("bitcoin.network.options.bitcoin")}
+
+
+ {t("bitcoin.network.options.testnet")}
+
+
+ {t("bitcoin.network.options.regtest")}
+
+
-
@@ -572,6 +472,25 @@ function AccountDetail() {
+ {hasMnemonic && (
+
+
+ {
+ removeMnemonic({
+ id: account.id,
+ name: account.name,
+ });
+ }}
+ label={t("actions.remove_secretkey")}
+ fullWidth
+ />
+
+
+ )}
-
-
-
-
- {t("nostr.generate_keys.title")}
-
-
-
-
-
-
- ,
- ]}
- />
-
-
-
- generateNostrPrivateKey(true)}
- label={t("nostr.generate_keys.actions.random_keys")}
- primary
- halfWidth
- />
- generateNostrPrivateKey()}
- label={t("nostr.generate_keys.actions.derived_keys")}
- halfWidth
- />
-
-
-
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);
+ }}
+ />
+
+ {t("backup.confirm")}
+
+
+ >
+
+ {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 ? (
+
+
+
+ ) : (
+
+ );
+}
+
+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() {