From 57d55c66774b40d37f8d22951140b5b3f6443974 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Fri, 30 Aug 2024 10:45:49 -0500 Subject: [PATCH] add account settings view --- src/app.tsx | 9 ++ src/classes/accounts/password-account.ts | 1 + src/classes/signers/password-signer.ts | 10 ++ src/components/account-info-badge.tsx | 2 +- src/components/layout/account-switcher.tsx | 33 +++-- src/services/account.ts | 23 +++- src/views/settings/accounts/index.tsx | 122 ++++++++++++++++++ .../accounts/password-signer-backup.tsx | 112 ++++++++++++++++ .../accounts/simple-signer-backup.tsx | 108 ++++++++++++++++ src/views/settings/index.tsx | 6 + src/views/signin/components/account-card.tsx | 4 +- 11 files changed, 409 insertions(+), 21 deletions(-) create mode 100644 src/views/settings/accounts/index.tsx create mode 100644 src/views/settings/accounts/password-signer-backup.tsx create mode 100644 src/views/settings/accounts/simple-signer-backup.tsx 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)} - + ( ))} - + + + } + 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)} - + }