From ee2b62f2d61d9e6ba8fcd8fdfbd7dd1e967298f3 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Sat, 17 Jan 2026 21:14:44 +0100 Subject: [PATCH] feat: enhance login options with read-only and nsec support (#126) * feat: enhance login options with read-only and nsec support - Add read-only login mode supporting: - npub (bech32 public key) - nprofile (bech32 profile with relay hints) - hex public key - NIP-05 addresses (user@domain.com) - Add private key (nsec) login with security warning - Supports nsec1... format - Supports 64-char hex private key - Shows prominent security warning about localStorage storage - Reorganize user menu to show login before theme option - Use ReadonlyAccount from applesauce-accounts for read-only mode - Use PrivateKeyAccount from applesauce-accounts for nsec login - Update LoginDialog with 4 tabs: Extension, Read-Only, Private Key, Remote - All account types properly registered via registerCommonAccountTypes() Technical notes: - ReadonlySigner throws errors on sign/encrypt operations - Existing components naturally handle accounts without signing capability - Hub/ActionRunner already syncs with account signers automatically * feat: add generate identity button to login dialog - Add "Generate Identity" button above login tabs - Uses Wand2 icon from lucide-react - Creates new key pair using PrivateKeyAccount.generateNew() - Automatically stores nsec in localStorage and sets as active account - Provides quick onboarding for new users without external wallet setup * feat: add useAccount hook for signing capability detection Created a centralized hook to check account signing capabilities and refactored components to distinguish between signing and read-only operations. New hook (src/hooks/useAccount.ts): - Returns account, pubkey, canSign, signer, isLoggedIn - Detects ReadonlyAccount vs signing accounts - Provides clear API for checking signing capability Refactored components: - ChatViewer: Use canSign for message composer, replying, actions - Show "Sign in to send messages" for read-only accounts - Disable message input for accounts without signing - SpellDialog: Use canSign for publishing spells - Show clear warning for read-only accounts - Updated error messages to mention read-only limitation - useEmojiSearch: Use pubkey for loading custom emoji lists - Works correctly with both signing and read-only accounts Benefits: - Clear separation between read (pubkey) and write (canSign, signer) operations - Read-only accounts can browse, view profiles, load data - Signing operations properly disabled for read-only accounts - Consistent pattern across the codebase for account checks - Better UX with specific messages about account capabilities --------- Co-authored-by: Claude --- src/components/ChatViewer.tsx | 31 ++- src/components/nostr/LoginDialog.tsx | 298 ++++++++++++++++++++++++++- src/components/nostr/SpellDialog.tsx | 22 +- src/components/nostr/user-menu.tsx | 58 +++--- src/hooks/useAccount.ts | 69 +++++++ src/hooks/useEmojiSearch.ts | 11 +- 6 files changed, 421 insertions(+), 68 deletions(-) create mode 100644 src/hooks/useAccount.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index b164539..f76f83c 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -15,7 +15,6 @@ import { import { nip19 } from "nostr-tools"; import { getZapRequest } from "applesauce-common/helpers/zap"; import { toast } from "sonner"; -import accountManager from "@/services/accounts"; import eventStore from "@/services/event-store"; import type { ChatProtocol, @@ -51,6 +50,7 @@ import { import { useProfileSearch } from "@/hooks/useProfileSearch"; import { useEmojiSearch } from "@/hooks/useEmojiSearch"; import { useCopy } from "@/hooks/useCopy"; +import { useAccount } from "@/hooks/useAccount"; import { Label } from "./ui/label"; import { Tooltip, @@ -437,9 +437,8 @@ export function ChatViewer({ }: ChatViewerProps) { const { addWindow } = useGrimoire(); - // Get active account - const activeAccount = use$(accountManager.active$); - const hasActiveAccount = !!activeAccount; + // Get active account with signing capability + const { pubkey, canSign, signer } = useAccount(); // Profile search for mentions const { searchProfiles } = useProfileSearch(); @@ -513,14 +512,14 @@ export function ChatViewer({ async (query: string) => { const availableActions = adapter.getActions({ conversation: conversation || undefined, - activePubkey: activeAccount?.pubkey, + activePubkey: pubkey, }); const lowerQuery = query.toLowerCase(); return availableActions.filter((action) => action.name.toLowerCase().includes(lowerQuery), ); }, - [adapter, conversation, activeAccount], + [adapter, conversation, pubkey], ); // Cleanup subscriptions when conversation changes or component unmounts @@ -596,7 +595,7 @@ export function ChatViewer({ emojiTags?: EmojiTag[], blobAttachments?: BlobAttachment[], ) => { - if (!conversation || !hasActiveAccount || isSending) return; + if (!conversation || !canSign || isSending) return; // Check if this is a slash command const slashCmd = parseSlashCommand(content); @@ -605,8 +604,8 @@ export function ChatViewer({ setIsSending(true); try { const result = await adapter.executeAction(slashCmd.command, { - activePubkey: activeAccount.pubkey, - activeSigner: activeAccount.signer, + activePubkey: pubkey!, + activeSigner: signer!, conversation, }); @@ -649,13 +648,13 @@ export function ChatViewer({ // Handle command execution from autocomplete const handleCommandExecute = useCallback( async (action: ChatAction) => { - if (!conversation || !hasActiveAccount || isSending) return; + if (!conversation || !canSign || isSending) return; setIsSending(true); try { const result = await adapter.executeAction(action.name, { - activePubkey: activeAccount.pubkey, - activeSigner: activeAccount.signer, + activePubkey: pubkey!, + activeSigner: signer!, conversation, }); @@ -673,7 +672,7 @@ export function ChatViewer({ setIsSending(false); } }, - [conversation, hasActiveAccount, isSending, adapter, activeAccount], + [conversation, canSign, isSending, adapter, pubkey, signer], ); // Handle reply button click @@ -987,7 +986,7 @@ export function ChatViewer({ adapter={adapter} conversation={conversation} onReply={handleReply} - canReply={hasActiveAccount} + canReply={canSign} onScrollToMessage={handleScrollToMessage} /> ); @@ -1001,8 +1000,8 @@ export function ChatViewer({ )} - {/* Message composer - only show if user has active account */} - {hasActiveAccount ? ( + {/* Message composer - only show if user can sign */} + {canSign ? (
{replyTo && ( (null); + // Read-only login state + const [readonlyInput, setReadonlyInput] = useState(""); + + // Private key (nsec) login state + const [nsecInput, setNsecInput] = useState(""); + // NIP-46 state const [bunkerUrl, setBunkerUrl] = useState(""); const [qrDataUrl, setQrDataUrl] = useState(null); @@ -72,6 +86,8 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { if (!open) { setLoading(false); setError(null); + setReadonlyInput(""); + setNsecInput(""); setBunkerUrl(""); setQrDataUrl(null); setConnectUri(null); @@ -84,7 +100,13 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { }, [open]); const handleSuccess = useCallback( - (account: ExtensionAccount | NostrConnectAccount) => { + ( + account: + | ExtensionAccount + | NostrConnectAccount + | ReadonlyAccount + | PrivateKeyAccount, + ) => { accounts.addAccount(account); accounts.setActive(account); onOpenChange(false); @@ -112,6 +134,127 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { } } + // Read-only login + async function loginWithReadonly() { + if (!readonlyInput.trim()) { + setError("Please enter a pubkey, npub, nprofile, or NIP-05 address"); + return; + } + + setLoading(true); + setError(null); + + try { + let pubkey: string; + + // Try npub/nprofile decode + if ( + readonlyInput.startsWith("npub") || + readonlyInput.startsWith("nprofile") + ) { + try { + const decoded = nip19.decode(readonlyInput); + if (decoded.type === "npub") { + pubkey = decoded.data; + } else if (decoded.type === "nprofile") { + pubkey = decoded.data.pubkey; + } else { + throw new Error("Invalid format"); + } + } catch (err) { + throw new Error( + `Invalid bech32 identifier: ${err instanceof Error ? err.message : "unknown error"}`, + ); + } + } + // Try hex pubkey + else if (isValidHexPubkey(readonlyInput)) { + pubkey = normalizeHex(readonlyInput); + } + // Try NIP-05 + else if (isNip05(readonlyInput)) { + const resolved = await resolveNip05(readonlyInput); + if (!resolved) { + throw new Error( + `Failed to resolve NIP-05 identifier: ${readonlyInput}`, + ); + } + pubkey = resolved; + } else { + throw new Error( + "Invalid format. Supported: npub1..., nprofile1..., hex pubkey, or user@domain.com", + ); + } + + const account = ReadonlyAccount.fromPubkey(pubkey); + handleSuccess(account); + } catch (err) { + console.error("Read-only login error:", err); + setError(err instanceof Error ? err.message : "Failed to create account"); + } finally { + setLoading(false); + } + } + + // Private key (nsec) login + async function loginWithNsec() { + if (!nsecInput.trim()) { + setError("Please enter a private key"); + return; + } + + setLoading(true); + setError(null); + + try { + let account: PrivateKeyAccount; + + // Try nsec decode + if (nsecInput.startsWith("nsec")) { + try { + account = PrivateKeyAccount.fromKey(nsecInput); + } catch (err) { + throw new Error( + `Invalid nsec: ${err instanceof Error ? err.message : "unknown error"}`, + ); + } + } + // Try hex private key + else if (/^[0-9a-f]{64}$/i.test(nsecInput)) { + account = PrivateKeyAccount.fromKey(nsecInput); + } else { + throw new Error( + "Invalid format. Supported: nsec1... or 64-character hex private key", + ); + } + + handleSuccess(account); + } catch (err) { + console.error("Nsec login error:", err); + setError(err instanceof Error ? err.message : "Failed to create account"); + } finally { + setLoading(false); + } + } + + // Generate new identity + async function generateNewIdentity() { + setLoading(true); + setError(null); + + try { + const account = PrivateKeyAccount.generateNew(); + handleSuccess(account); + } catch (err) { + console.error("Generate identity error:", err); + setError( + err instanceof Error ? err.message : "Failed to generate identity", + ); + } finally { + setLoading(false); + } + } + // Bunker URL login async function loginWithBunkerUrl() { if (!bunkerUrl.trim()) { @@ -266,15 +409,42 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { + + setTab(v as LoginTab)}> - + - Extension + Extension + + + + Read-Only + + + + Private Key - Nostr Connect + Remote @@ -317,6 +487,122 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { + +

+ Browse Nostr in read-only mode. You can view content but cannot + sign events or post. +

+ + {error && tab === "readonly" && ( +
+ + {error} +
+ )} + +
+ + setReadonlyInput(e.target.value)} + disabled={loading} + /> +

+ Supports npub, nprofile, hex pubkey, or NIP-05 addresses +

+
+ + +
+ + +
+ +
+

Security Warning

+

+ Entering your private key is not recommended. Your key will be + stored in browser localStorage and could be exposed. Consider + using an extension or remote signer instead. +

+
+
+ +

+ Log in by pasting your private key (nsec or hex format). Only use + this on trusted devices. +

+ + {error && tab === "nsec" && ( +
+ + {error} +
+ )} + +
+ + setNsecInput(e.target.value)} + disabled={loading} + /> +

+ Supports nsec or 64-character hex private key +

+
+ + +
+

Log in using NIP-46 remote signing. Scan the QR code with a signer diff --git a/src/components/nostr/SpellDialog.tsx b/src/components/nostr/SpellDialog.tsx index 9a365d4..4c7207d 100644 --- a/src/components/nostr/SpellDialog.tsx +++ b/src/components/nostr/SpellDialog.tsx @@ -11,8 +11,6 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; -import { use$ } from "applesauce-react/hooks"; -import accounts from "@/services/accounts"; import { parseReqCommand } from "@/lib/req-parser"; import { reconstructCommand, detectCommandType } from "@/lib/spell-conversion"; import type { ParsedSpell, SpellEvent } from "@/types/spell"; @@ -20,6 +18,7 @@ import { Loader2 } from "lucide-react"; import { saveSpell } from "@/services/spell-storage"; import { LocalSpell } from "@/services/db"; import { PublishSpellAction } from "@/actions/publish-spell"; +import { useAccount } from "@/hooks/useAccount"; /** * Filter command to show only spell-relevant parts @@ -79,7 +78,7 @@ export function SpellDialog({ existingSpell, onSuccess, }: SpellDialogProps) { - const activeAccount = use$(accounts.active$); + const { canSign } = useAccount(); // Form state const [alias, setAlias] = useState(""); @@ -186,9 +185,11 @@ export function SpellDialog({ const handlePublish = async () => { if (!isFormValid) return; - // Check for active account - if (!activeAccount) { - setErrorMessage("No active account. Please sign in first."); + // Check for signing capability + if (!canSign) { + setErrorMessage( + "You need a signing account to publish. Read-only accounts cannot publish.", + ); setPublishingState("error"); return; } @@ -363,10 +364,11 @@ export function SpellDialog({

)} - {/* No account warning */} - {!activeAccount && ( + {/* No signing capability warning */} + {!canSign && (
- You need to sign in to publish spells. + You need a signing account to publish spells. Read-only accounts + cannot publish.
)} @@ -384,7 +386,7 @@ export function SpellDialog({