From 2f66322afe95eba54a0bc091f838217f08333a42 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Jan 2026 19:49:02 +0000 Subject: [PATCH] feat: Add identity generation and nsec import to login dialog - Add new "Create/Import" tab to LoginDialog with two options: 1. Generate New Identity: Creates random private key and displays nsec for backup 2. Import Private Key: Allows nsec/hex import with security warning - Generate identity shows a yellow warning box with the generated nsec - Import has a two-step flow with security warning before showing input field - Both options create PrivateKeyAccount and persist to localStorage - Supports both nsec1... and hex format for private key import - Password-type input for nsec to prevent shoulder surfing - Added KeyRound and Shield icons from lucide-react - Imports nip19 for nsec encoding/decoding and getPublicKey from nostr-tools/pure All tests pass (838/838) and build succeeds --- src/components/nostr/LoginDialog.tsx | 290 ++++++++++++++++++++++++++- 1 file changed, 286 insertions(+), 4 deletions(-) diff --git a/src/components/nostr/LoginDialog.tsx b/src/components/nostr/LoginDialog.tsx index c6dc1f8..f4f9595 100644 --- a/src/components/nostr/LoginDialog.tsx +++ b/src/components/nostr/LoginDialog.tsx @@ -7,8 +7,10 @@ import { import { ExtensionAccount, NostrConnectAccount, + PrivateKeyAccount, } from "applesauce-accounts/accounts"; -import { generateSecretKey } from "nostr-tools"; +import { generateSecretKey, getPublicKey } from "nostr-tools/pure"; +import { nip19 } from "nostr-tools"; import QRCode from "qrcode"; import { Dialog, @@ -27,6 +29,8 @@ import { Copy, Check, AlertCircle, + KeyRound, + Shield, } from "lucide-react"; import accounts from "@/services/accounts"; import pool from "@/services/relay-pool"; @@ -38,7 +42,7 @@ const DEFAULT_NIP46_RELAYS = [ "wss://nos.lol", ]; -type LoginTab = "extension" | "nostr-connect"; +type LoginTab = "extension" | "nostr-connect" | "create-import"; interface LoginDialogProps { open: boolean; @@ -59,6 +63,11 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { const abortControllerRef = useRef(null); const signerRef = useRef(null); + // Create/Import state + const [nsecInput, setNsecInput] = useState(""); + const [generatedNsec, setGeneratedNsec] = useState(null); + const [showNsecWarning, setShowNsecWarning] = useState(false); + // Cleanup on unmount or dialog close useEffect(() => { return () => { @@ -77,6 +86,9 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { setConnectUri(null); setWaitingForSigner(false); setCopied(false); + setNsecInput(""); + setGeneratedNsec(null); + setShowNsecWarning(false); abortControllerRef.current?.abort(); signerRef.current?.close(); signerRef.current = null; @@ -84,7 +96,12 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { }, [open]); const handleSuccess = useCallback( - (account: ExtensionAccount | NostrConnectAccount) => { + ( + account: + | ExtensionAccount + | NostrConnectAccount + | PrivateKeyAccount, + ) => { accounts.addAccount(account); accounts.setActive(account); onOpenChange(false); @@ -254,6 +271,102 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { setWaitingForSigner(false); } + // Generate new identity + async function generateIdentity() { + setLoading(true); + setError(null); + + try { + // Generate a new secret key + const secretKey = generateSecretKey(); + const pubkey = getPublicKey(secretKey); + + // Convert to nsec for display + const nsec = nip19.nsecEncode(secretKey); + setGeneratedNsec(nsec); + + // Create signer and account + const signer = new PrivateKeySigner(secretKey); + const account = new PrivateKeyAccount(pubkey, signer); + + handleSuccess(account); + } catch (err) { + console.error("Generate identity error:", err); + setError( + err instanceof Error ? err.message : "Failed to generate identity", + ); + } finally { + setLoading(false); + } + } + + // Login with nsec + async function loginWithNsec() { + if (!nsecInput.trim()) { + setError("Please enter your nsec"); + return; + } + + setLoading(true); + setError(null); + + try { + let secretKey: Uint8Array; + + // Try to decode as nsec first + if (nsecInput.startsWith("nsec1")) { + const decoded = nip19.decode(nsecInput.trim()); + if (decoded.type !== "nsec") { + throw new Error("Invalid nsec format"); + } + secretKey = decoded.data; + } else { + // Try to decode as hex + secretKey = new Uint8Array( + nsecInput + .trim() + .match(/.{1,2}/g) + ?.map((byte) => parseInt(byte, 16)) || [], + ); + if (secretKey.length !== 32) { + throw new Error( + "Invalid key format. Please enter a valid nsec or hex private key", + ); + } + } + + const pubkey = getPublicKey(secretKey); + + // Create signer and account + const signer = new PrivateKeySigner(secretKey); + const account = new PrivateKeyAccount(pubkey, signer); + + handleSuccess(account); + } catch (err) { + console.error("Nsec login error:", err); + setError( + err instanceof Error + ? err.message + : "Failed to import private key. Please check your nsec and try again.", + ); + } finally { + setLoading(false); + } + } + + // Copy generated nsec to clipboard + async function copyGeneratedNsec() { + if (!generatedNsec) return; + + try { + await navigator.clipboard.writeText(generatedNsec); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy:", err); + } + } + const hasExtension = typeof window !== "undefined" && "nostr" in window; return ( @@ -267,7 +380,7 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { setTab(v as LoginTab)}> - + Extension @@ -276,6 +389,10 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { Nostr Connect + + + Create/Import + @@ -443,6 +560,171 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { )} + + +

+ Create a new Nostr identity or import an existing private key. +

+ + {error && tab === "create-import" && ( +
+ + {error} +
+ )} + + {/* Generate Identity Section */} +
+
+ +

Generate New Identity

+
+

+ Create a brand new Nostr identity with a randomly generated + private key. Make sure to back up your key! +

+ + {generatedNsec && ( +
+
+ +
+

+ Save your private key (nsec) +

+

+ This is your ONLY copy. Store it somewhere safe. Anyone + with this key can control your identity. +

+
+
+
+ {generatedNsec} +
+ +
+ )} + + +
+ + {/* Divider */} +
+
+ +
+
+ + Or import existing key + +
+
+ + {/* Import nsec Section */} +
+
+ +

Import Private Key

+
+ + {/* Security Warning */} + {!showNsecWarning ? ( +
+
+ +
+

Security Warning

+

+ Never paste your private key into websites you don't + trust. This key gives full control over your Nostr + identity. +

+
+
+ +
+ ) : ( +
+

+ Enter your private key in nsec or hex format. +

+ +
+ + setNsecInput(e.target.value)} + disabled={loading} + className="font-mono" + /> +
+ + +
+ )} +
+