add account settings view

This commit is contained in:
hzrd149
2024-08-30 10:45:49 -05:00
parent 24e2a82b94
commit 57d55c6677
11 changed files with 409 additions and 21 deletions

View File

@@ -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: <DisplaySettings /> },
{ path: "post", element: <PostSettings /> },
{
path: "accounts",
element: (
<RequireCurrentAccount>
<AccountSettings />
</RequireCurrentAccount>
),
},
{ path: "display", element: <DisplaySettings /> },
{ path: "privacy", element: <PrivacySettings /> },
{ path: "lightning", element: <LightningSettings /> },

View File

@@ -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 });
}

View File

@@ -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;

View File

@@ -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":

View File

@@ -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
<UserAvatar pubkey={pubkey} size="md" />
<Flex direction="column" overflow="hidden" alignItems="flex-start">
<Text isTruncated>{getDisplayName(metadata, pubkey)}</Text>
<AccountInfoBadge fontSize="0.7em" account={account} />
<AccountTypeBadge fontSize="0.7em" account={account} />
</Flex>
</Flex>
<IconButton
@@ -79,15 +79,22 @@ export default function AccountSwitcher() {
{otherAccounts.map((account) => (
<AccountItem key={account.pubkey} account={account} onClick={onClose} />
))}
<Button
leftIcon={<AddIcon />}
onClick={() => {
accountService.logout(false);
navigate("/signin", { state: { from: location.pathname } });
}}
>
Add Account
</Button>
<ButtonGroup>
<Button as={RouterLink} to="/settings/accounts" w="full">
Manage
</Button>
<IconButton
icon={<AddIcon boxSize={6} />}
aria-label="Add Account"
onClick={() => {
accountService.logout(false);
navigate("/signin", { state: { from: location.pathname } });
}}
colorScheme="primary"
>
Add Account
</IconButton>
</ButtonGroup>
</>
)}
</Flex>

View File

@@ -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);
}
}

View File

@@ -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 <PasswordSignerBackup />;
}
if (account.signer instanceof SimpleSigner && account.signer.key) {
return <SimpleSignerBackup />;
}
return null;
}
export default function AccountSettings() {
const account = useCurrentAccount()!;
const accounts = useSubject(accountService.accounts);
const navigate = useNavigate();
return (
<VerticalPageLayout flex={1}>
<Flex gap="2" alignItems="center">
<Heading size="md">Account Settings</Heading>
<Button
variant="outline"
colorScheme="primary"
ml="auto"
size="sm"
onClick={() => {
accountService.logout(false);
navigate("/signin", { state: { from: location.pathname } });
}}
>
Add Account
</Button>
</Flex>
<Flex gap="2" alignItems="center" wrap="wrap">
<UserAvatar pubkey={account.pubkey} />
<Box lineHeight={1}>
<Heading size="lg">
<UserName pubkey={account.pubkey} />
</Heading>
<UserDnsIdentity pubkey={account.pubkey} />
</Box>
<AccountTypeBadge account={account} ml="4" />
<Button onClick={() => accountService.logout()} ml="auto">
Logout
</Button>
</Flex>
<AccountBackup />
<Flex gap="2" px="4" alignItems="Center" mt="4">
<Divider />
<Text fontWeight="bold" fontSize="md" whiteSpace="pre">
Other Accounts
</Text>
<Divider />
</Flex>
{accounts
.filter((a) => a.pubkey !== account.pubkey)
.map((account) => (
<Flex gap="2" alignItems="center" wrap="wrap" key={account.pubkey}>
<UserAvatar pubkey={account.pubkey} />
<Box lineHeight={1}>
<Heading size="lg">
<UserName pubkey={account.pubkey} />
</Heading>
<UserDnsIdentity pubkey={account.pubkey} />
</Box>
<AccountTypeBadge account={account} ml="4" />
<ButtonGroup size="sm" ml="auto">
<Button
onClick={() => accountService.switchAccount(account.pubkey)}
colorScheme="primary"
variant="ghost"
>
Switch
</Button>
<Button
onClick={() => confirm("Remove account?") && accountService.removeAccount(account)}
colorScheme="red"
>
Remove
</Button>
</ButtonGroup>
</Flex>
))}
</VerticalPageLayout>
);
}

View File

@@ -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 (
<>
<FormControl>
<FormLabel>Encrypted secret key</FormLabel>
<Flex gap="2">
<Input value={signer.ncryptsec} readOnly placeholder="Click to show nsec" userSelect="all" />
<CopyIconButton value={signer.ncryptsec} aria-label="Copy nsec to clipboard" />
</Flex>
</FormControl>
<Flex as="form" onSubmit={changePassword} direction="column" gap="2">
<Heading size="md" mt="2">
Change password
</Heading>
<Input
maxW="sm"
type={sensitive.isOpen ? "text" : "password"}
placeholder="Current Password"
autoComplete="off"
{...register("current", { required: true })}
isRequired
/>
<FormControl>
<FormLabel>New Password</FormLabel>
<Flex direction="column" gap="2" maxW="sm">
<Input
placeholder="New Password"
type={sensitive.isOpen ? "text" : "password"}
autoComplete="off"
{...register("new", { required: true })}
isRequired
/>
<Input
placeholder="Repeat Password"
type={sensitive.isOpen ? "text" : "password"}
autoComplete="off"
{...register("repeat", { required: true })}
isRequired
/>
{formState.isDirty && (
<ButtonGroup size="sm" ml="auto">
<IconButton
type="button"
aria-label="Show passwords"
icon={sensitive.isOpen ? <Eye boxSize={5} /> : <EyeOff boxSize={5} />}
onClick={sensitive.onToggle}
/>
<Button type="submit">Change</Button>
</ButtonGroup>
)}
</Flex>
</FormControl>
</Flex>
</>
);
}

View File

@@ -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 (
<>
<FormControl>
<FormLabel>Secret key</FormLabel>
<Flex gap="2">
{!backup && (
<IconButton
aria-label="Show sensitive data"
icon={backup ? <Eye boxSize={5} /> : <EyeOff boxSize={5} />}
onClick={showSecret}
/>
)}
<Input
value={backup ? backup : fake}
type={backup ? "text" : "password"}
readOnly
placeholder="Click to show nsec"
userSelect="all"
/>
{backup && <CopyIconButton value={backup} aria-label="Copy nsec to clipboard" />}
</Flex>
</FormControl>
<Flex as="form" onSubmit={setPassword} direction="column" gap="2">
<Heading size="md" mt="2">
Add password
</Heading>
<Flex gap="2" maxW="lg">
<Input
type={sensitive.isOpen ? "text" : "password"}
placeholder="Current Password"
autoComplete="off"
{...register("password", { required: true })}
isRequired
/>
<IconButton
type="button"
aria-label="Show passwords"
icon={sensitive.isOpen ? <Eye boxSize={5} /> : <EyeOff boxSize={5} />}
onClick={sensitive.onToggle}
/>
<Button type="submit">Set</Button>
</Flex>
</Flex>
</>
);
}

View File

@@ -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() {
<Flex overflowY="auto" overflowX="hidden" h="full" minW="xs" direction="column">
<Heading title="Settings" />
<Flex direction="column" p="2" gap="2">
{account && (
<SimpleNavItem to="/settings/accounts" leftIcon={<UserAvatar size="xs" pubkey={account.pubkey} />}>
Accounts
</SimpleNavItem>
)}
<SimpleNavItem to="/settings/display" leftIcon={<AppearanceIcon boxSize={5} />}>
Display
</SimpleNavItem>

View File

@@ -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 }) {
<Text isTruncated fontWeight="bold">
{getDisplayName(metadata, pubkey)}
</Text>
<AccountInfoBadge account={account} />
<AccountTypeBadge account={account} />
</Box>
<IconButton
icon={<CloseIcon />}