mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-20 04:20:39 +02:00
add account settings view
This commit is contained in:
@@ -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 /> },
|
||||
|
@@ -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 });
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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":
|
||||
|
@@ -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>
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
122
src/views/settings/accounts/index.tsx
Normal file
122
src/views/settings/accounts/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
112
src/views/settings/accounts/password-signer-backup.tsx
Normal file
112
src/views/settings/accounts/password-signer-backup.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
108
src/views/settings/accounts/simple-signer-backup.tsx
Normal file
108
src/views/settings/accounts/simple-signer-backup.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
|
@@ -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 />}
|
||||
|
Reference in New Issue
Block a user