diff --git a/src/app.tsx b/src/app.tsx
index dfde5cb5b..813ccb7c5 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -102,6 +102,7 @@ import LightningSettings from "./views/settings/lightning";
import PerformanceSettings from "./views/settings/performance";
import PrivacySettings from "./views/settings/privacy";
import PostSettings from "./views/settings/post";
+import AccountSettings from "./views/settings/accounts";
const TracksView = lazy(() => import("./views/tracks"));
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const UserVideosTab = lazy(() => import("./views/user/videos"));
@@ -265,6 +266,14 @@ const router = createHashRouter([
children: [
{ path: "", element: },
{ path: "post", element: },
+ {
+ path: "accounts",
+ element: (
+
+
+
+ ),
+ },
{ path: "display", element: },
{ path: "privacy", element: },
{ path: "lightning", element: },
diff --git a/src/classes/accounts/password-account.ts b/src/classes/accounts/password-account.ts
index e1501d07a..14ac9d86c 100644
--- a/src/classes/accounts/password-account.ts
+++ b/src/classes/accounts/password-account.ts
@@ -19,6 +19,7 @@ export default class PasswordAccount extends Account {
static fromNcryptsec(pubkey: string, ncryptsec: string) {
const account = new PasswordAccount(pubkey);
+ account.pubkey = pubkey;
return account.fromJSON({ ncryptsec });
}
diff --git a/src/classes/signers/password-signer.ts b/src/classes/signers/password-signer.ts
index 9bef6e6a4..ae8bc358e 100644
--- a/src/classes/signers/password-signer.ts
+++ b/src/classes/signers/password-signer.ts
@@ -130,6 +130,16 @@ export default class PasswordSigner implements Nip07Signer {
this.ncryptsec = encrypt(this.key, password);
}
+ public async testPassword(password: string) {
+ if (this.ncryptsec) {
+ const key = decrypt(this.ncryptsec, password);
+ if (!key) throw new Error("Failed to decrypt key");
+ } else if (this.buffer && this.iv) {
+ const key = await subltCryptoDecryptSecKey(this.buffer, this.iv, password);
+ if (!key) throw new Error("Failed to decrypt key");
+ } else throw new Error("Missing array buffer and iv");
+ }
+
public async unlock(password: string) {
if (this.key) return;
diff --git a/src/components/account-info-badge.tsx b/src/components/account-info-badge.tsx
index 307e2efa1..fc9052247 100644
--- a/src/components/account-info-badge.tsx
+++ b/src/components/account-info-badge.tsx
@@ -1,7 +1,7 @@
import { Badge, BadgeProps } from "@chakra-ui/react";
import { Account } from "../classes/accounts/account";
-export default function AccountInfoBadge({ account, ...props }: BadgeProps & { account: Account }) {
+export default function AccountTypeBadge({ account, ...props }: BadgeProps & { account: Account }) {
let color = "gray";
switch (account.type) {
case "extension":
diff --git a/src/components/layout/account-switcher.tsx b/src/components/layout/account-switcher.tsx
index 64b4394c3..fc0ab8ffa 100644
--- a/src/components/layout/account-switcher.tsx
+++ b/src/components/layout/account-switcher.tsx
@@ -1,6 +1,6 @@
import { CloseIcon } from "@chakra-ui/icons";
-import { useNavigate } from "react-router-dom";
-import { Box, Button, Flex, IconButton, Text, useDisclosure } from "@chakra-ui/react";
+import { useNavigate, Link as RouterLink } from "react-router-dom";
+import { Box, Button, ButtonGroup, Flex, IconButton, Text, useDisclosure } from "@chakra-ui/react";
import { getDisplayName } from "../../helpers/nostr/user-metadata";
import useSubject from "../../hooks/use-subject";
@@ -8,7 +8,7 @@ import useUserMetadata from "../../hooks/use-user-metadata";
import accountService from "../../services/account";
import { AddIcon, ChevronDownIcon, ChevronUpIcon } from "../icons";
import UserAvatar from "../user/user-avatar";
-import AccountInfoBadge from "../account-info-badge";
+import AccountTypeBadge from "../account-info-badge";
import useCurrentAccount from "../../hooks/use-current-account";
import { Account } from "../../classes/accounts/account";
@@ -27,7 +27,7 @@ function AccountItem({ account, onClick }: { account: Account; onClick?: () => v
{getDisplayName(metadata, pubkey)}
-
+
(
))}
- }
- onClick={() => {
- accountService.logout(false);
- navigate("/signin", { state: { from: location.pathname } });
- }}
- >
- Add Account
-
+
+
+ }
+ aria-label="Add Account"
+ onClick={() => {
+ accountService.logout(false);
+ navigate("/signin", { state: { from: location.pathname } });
+ }}
+ colorScheme="primary"
+ >
+ Add Account
+
+
>
)}
diff --git a/src/services/account.ts b/src/services/account.ts
index 97ecbb118..4466c208b 100644
--- a/src/services/account.ts
+++ b/src/services/account.ts
@@ -124,12 +124,25 @@ class AccountService {
if (account) account.localSettings = settings;
}
- switchAccount(pubkey: string) {
- const account = this.accounts.value.find((acc) => acc.pubkey === pubkey);
- if (account) {
- this.current.next(account);
+ switchAccount(account: Account | string) {
+ const newCurrent =
+ typeof account === "string" ? this.accounts.value.find((acc) => acc.pubkey === account) : account;
+
+ if (newCurrent) {
+ this.current.next(newCurrent);
this.isGhost.next(false);
- localStorage.setItem("lastAccount", pubkey);
+ localStorage.setItem("lastAccount", newCurrent.pubkey);
+ }
+ }
+
+ replaceAccount(oldPubkey: string, newAccount: Account, change = true) {
+ const account = this.accounts.value.find((acc) => acc.pubkey === oldPubkey);
+
+ if (account) {
+ this.current.next(newAccount);
+ this.accounts.next([...this.accounts.value.filter((acc) => acc !== account), newAccount]);
+ this.isGhost.next(false);
+ localStorage.setItem("lastAccount", newAccount.pubkey);
}
}
diff --git a/src/views/settings/accounts/index.tsx b/src/views/settings/accounts/index.tsx
new file mode 100644
index 000000000..b5b0c1b50
--- /dev/null
+++ b/src/views/settings/accounts/index.tsx
@@ -0,0 +1,122 @@
+import {
+ Box,
+ Button,
+ ButtonGroup,
+ Divider,
+ Flex,
+ FormControl,
+ FormLabel,
+ Heading,
+ Input,
+ Text,
+} from "@chakra-ui/react";
+import { Link as RouterLink, useNavigate } from "react-router-dom";
+
+import VerticalPageLayout from "../../../components/vertical-page-layout";
+import useCurrentAccount from "../../../hooks/use-current-account";
+import UserAvatar from "../../../components/user/user-avatar";
+import UserName from "../../../components/user/user-name";
+import UserDnsIdentity from "../../../components/user/user-dns-identity";
+import accountService from "../../../services/account";
+import AccountTypeBadge from "../../../components/account-info-badge";
+import useSubject from "../../../hooks/use-subject";
+import PasswordSigner from "../../../classes/signers/password-signer";
+import SimpleSigner from "../../../classes/signers/simple-signer";
+import SimpleSignerBackup from "./simple-signer-backup";
+import PasswordSignerBackup from "./password-signer-backup";
+
+function AccountBackup() {
+ const account = useCurrentAccount()!;
+
+ if (account.signer instanceof PasswordSigner && account.signer.ncryptsec) {
+ return ;
+ }
+
+ if (account.signer instanceof SimpleSigner && account.signer.key) {
+ return ;
+ }
+
+ return null;
+}
+
+export default function AccountSettings() {
+ const account = useCurrentAccount()!;
+ const accounts = useSubject(accountService.accounts);
+ const navigate = useNavigate();
+
+ return (
+
+
+ Account Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Other Accounts
+
+
+
+
+ {accounts
+ .filter((a) => a.pubkey !== account.pubkey)
+ .map((account) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/views/settings/accounts/password-signer-backup.tsx b/src/views/settings/accounts/password-signer-backup.tsx
new file mode 100644
index 000000000..38c734950
--- /dev/null
+++ b/src/views/settings/accounts/password-signer-backup.tsx
@@ -0,0 +1,112 @@
+import { useCallback, useState } from "react";
+import {
+ Button,
+ ButtonGroup,
+ Flex,
+ FormControl,
+ FormLabel,
+ Heading,
+ IconButton,
+ Input,
+ useDisclosure,
+ useToast,
+} from "@chakra-ui/react";
+import { nip19 } from "nostr-tools";
+import { encrypt } from "nostr-tools/nip49";
+
+import SimpleSigner from "../../../classes/signers/simple-signer";
+import useCurrentAccount from "../../../hooks/use-current-account";
+import EyeOff from "../../../components/icons/eye-off";
+import Eye from "../../../components/icons/eye";
+import { CopyIconButton } from "../../../components/copy-icon-button";
+import PasswordSigner from "../../../classes/signers/password-signer";
+import { useForm } from "react-hook-form";
+
+const fake = Array(48).fill("x");
+
+export default function PasswordSignerBackup() {
+ const toast = useToast();
+ const account = useCurrentAccount()!;
+ const signer = account.signer;
+ if (!(signer instanceof PasswordSigner)) return null;
+
+ const sensitive = useDisclosure();
+ const { register, handleSubmit, formState, reset } = useForm({
+ defaultValues: { current: "", new: "", repeat: "" },
+ mode: "all",
+ });
+
+ const changePassword = handleSubmit(async (values) => {
+ try {
+ if (values.repeat !== values.new) throw new Error("New passwords do not match");
+
+ try {
+ if (!signer.unlocked) await signer.unlock(values.current);
+ else await signer.testPassword(values.current);
+ } catch (error) {
+ throw new Error("Bad password");
+ }
+
+ await signer.setPassword(values.new);
+ reset();
+ toast({ description: "Changed password", status: "success" });
+ } catch (error) {
+ if (error instanceof Error) toast({ description: error.message, status: "error" });
+ }
+ });
+
+ return (
+ <>
+
+ Encrypted secret key
+
+
+
+
+
+
+
+ Change password
+
+
+
+ New Password
+
+
+
+ {formState.isDirty && (
+
+ : }
+ onClick={sensitive.onToggle}
+ />
+
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/src/views/settings/accounts/simple-signer-backup.tsx b/src/views/settings/accounts/simple-signer-backup.tsx
new file mode 100644
index 000000000..73781db45
--- /dev/null
+++ b/src/views/settings/accounts/simple-signer-backup.tsx
@@ -0,0 +1,108 @@
+import { useCallback, useState } from "react";
+import {
+ Button,
+ ButtonGroup,
+ Flex,
+ FormControl,
+ FormHelperText,
+ FormLabel,
+ Heading,
+ IconButton,
+ Input,
+ useDisclosure,
+ useToast,
+} from "@chakra-ui/react";
+import { nip19 } from "nostr-tools";
+import { encrypt } from "nostr-tools/nip49";
+import { useForm } from "react-hook-form";
+
+import SimpleSigner from "../../../classes/signers/simple-signer";
+import useCurrentAccount from "../../../hooks/use-current-account";
+import EyeOff from "../../../components/icons/eye-off";
+import Eye from "../../../components/icons/eye";
+import { CopyIconButton } from "../../../components/copy-icon-button";
+import accountService from "../../../services/account";
+import PasswordAccount from "../../../classes/accounts/password-account";
+
+const fake = Array(48).fill("x");
+
+export default function SimpleSignerBackup() {
+ const toast = useToast();
+ const account = useCurrentAccount()!;
+ const signer = account.signer;
+ if (!(signer instanceof SimpleSigner)) return null;
+
+ const [backup, setBackup] = useState("");
+ const sensitive = useDisclosure();
+
+ const showSecret = useCallback(async () => {
+ const password = prompt("Its dangerous to export secret keys unprotected! do you want to add a password?");
+ if (password) {
+ setBackup(encrypt(signer.key, password));
+ } else {
+ setBackup(nip19.nsecEncode(signer.key));
+ }
+ }, [setBackup, signer]);
+
+ const { register, handleSubmit, reset } = useForm({ defaultValues: { password: "" }, mode: "all" });
+
+ const setPassword = handleSubmit(async (values) => {
+ try {
+ if (!values.password) throw new Error("Missing password");
+
+ const newAccount = PasswordAccount.fromNcryptsec(account.pubkey, encrypt(signer.key, values.password));
+ newAccount.pubkey = account.pubkey;
+ await newAccount.signer.unlock(values.password);
+ accountService.replaceAccount(account.pubkey, newAccount, true);
+ reset();
+ } catch (error) {
+ if (error instanceof Error) toast({ description: error.message, status: "error" });
+ }
+ });
+
+ return (
+ <>
+
+ Secret key
+
+ {!backup && (
+ : }
+ onClick={showSecret}
+ />
+ )}
+
+ {backup && }
+
+
+
+
+ Add password
+
+
+
+ : }
+ onClick={sensitive.onToggle}
+ />
+
+
+
+ >
+ );
+}
diff --git a/src/views/settings/index.tsx b/src/views/settings/index.tsx
index e2129243f..478ab5ac0 100644
--- a/src/views/settings/index.tsx
+++ b/src/views/settings/index.tsx
@@ -14,6 +14,7 @@ import {
} from "../../components/icons";
import useCurrentAccount from "../../hooks/use-current-account";
import Image01 from "../../components/icons/image-01";
+import UserAvatar from "../../components/user/user-avatar";
export default function SettingsView() {
const account = useCurrentAccount();
@@ -27,6 +28,11 @@ export default function SettingsView() {
+ {account && (
+ }>
+ Accounts
+
+ )}
}>
Display
diff --git a/src/views/signin/components/account-card.tsx b/src/views/signin/components/account-card.tsx
index 8e8f96122..4761a07da 100644
--- a/src/views/signin/components/account-card.tsx
+++ b/src/views/signin/components/account-card.tsx
@@ -5,7 +5,7 @@ import { getDisplayName } from "../../../helpers/nostr/user-metadata";
import useUserMetadata from "../../../hooks/use-user-metadata";
import accountService from "../../../services/account";
import UserAvatar from "../../../components/user/user-avatar";
-import AccountInfoBadge from "../../../components/account-info-badge";
+import AccountTypeBadge from "../../../components/account-info-badge";
import { Account } from "../../../classes/accounts/account";
export default function AccountCard({ account }: { account: Account }) {
@@ -30,7 +30,7 @@ export default function AccountCard({ account }: { account: Account }) {
{getDisplayName(metadata, pubkey)}
-
+
}