From 2534507a156a0270acbda81c7aed7adfae409a6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Jan 2026 19:44:01 +0000 Subject: [PATCH] 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 --- src/components/nostr/LoginDialog.tsx | 260 ++++++++++++++++++++++++++- src/components/nostr/user-menu.tsx | 58 +++--- 2 files changed, 283 insertions(+), 35 deletions(-) diff --git a/src/components/nostr/LoginDialog.tsx b/src/components/nostr/LoginDialog.tsx index c6dc1f8..cca5616 100644 --- a/src/components/nostr/LoginDialog.tsx +++ b/src/components/nostr/LoginDialog.tsx @@ -7,8 +7,10 @@ import { import { ExtensionAccount, NostrConnectAccount, + ReadonlyAccount, + PrivateKeyAccount, } from "applesauce-accounts/accounts"; -import { generateSecretKey } from "nostr-tools"; +import { generateSecretKey, nip19 } from "nostr-tools"; import QRCode from "qrcode"; import { Dialog, @@ -27,9 +29,14 @@ import { Copy, Check, AlertCircle, + Eye, + Key, + ShieldAlert, } from "lucide-react"; import accounts from "@/services/accounts"; import pool from "@/services/relay-pool"; +import { resolveNip05, isNip05 } from "@/lib/nip05"; +import { isValidHexPubkey, normalizeHex } from "@/lib/nostr-validation"; // Default relays for NIP-46 communication const DEFAULT_NIP46_RELAYS = [ @@ -38,7 +45,7 @@ const DEFAULT_NIP46_RELAYS = [ "wss://nos.lol", ]; -type LoginTab = "extension" | "nostr-connect"; +type LoginTab = "extension" | "readonly" | "nsec" | "nostr-connect"; interface LoginDialogProps { open: boolean; @@ -50,6 +57,12 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(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 +85,8 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { if (!open) { setLoading(false); setError(null); + setReadonlyInput(""); + setNsecInput(""); setBunkerUrl(""); setQrDataUrl(null); setConnectUri(null); @@ -84,7 +99,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 +133,109 @@ 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); + } + } + // Bunker URL login async function loginWithBunkerUrl() { if (!bunkerUrl.trim()) { @@ -267,14 +391,22 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { setTab(v as LoginTab)}> - + - Extension + Extension + + + + Read-Only + + + + Private Key - Nostr Connect + Remote @@ -317,6 +449,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/user-menu.tsx b/src/components/nostr/user-menu.tsx index 111a2ea..4bb555e 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -157,38 +157,11 @@ export default function UserMenu() { )} - - - - - Theme - - - {availableThemes.map((theme) => ( - setTheme(theme.id)} - > - - {theme.name} - - ))} - - Log out - - ) : ( - <> + @@ -213,10 +186,37 @@ export default function UserMenu() { ))} - + + ) : ( + <> setShowLogin(true)}> Log in + + + + + Theme + + + {availableThemes.map((theme) => ( + setTheme(theme.id)} + > + + {theme.name} + + ))} + + )}