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
This commit is contained in:
Claude
2026-01-14 19:49:02 +00:00
parent f464c68bde
commit 2f66322afe

View File

@@ -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<AbortController | null>(null);
const signerRef = useRef<NostrConnectSigner | null>(null);
// Create/Import state
const [nsecInput, setNsecInput] = useState("");
const [generatedNsec, setGeneratedNsec] = useState<string | null>(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<unknown> | NostrConnectAccount<unknown>) => {
(
account:
| ExtensionAccount<unknown>
| NostrConnectAccount<unknown>
| PrivateKeyAccount<unknown>,
) => {
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) {
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => setTab(v as LoginTab)}>
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="extension" className="gap-2">
<Puzzle className="size-4" />
Extension
@@ -276,6 +389,10 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
<QrCode className="size-4" />
Nostr Connect
</TabsTrigger>
<TabsTrigger value="create-import" className="gap-2">
<KeyRound className="size-4" />
Create/Import
</TabsTrigger>
</TabsList>
<TabsContent value="extension" className="space-y-4">
@@ -443,6 +560,171 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
)}
</Button>
</TabsContent>
<TabsContent value="create-import" className="space-y-4">
<p className="text-sm text-muted-foreground">
Create a new Nostr identity or import an existing private key.
</p>
{error && tab === "create-import" && (
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Generate Identity Section */}
<div className="space-y-3 rounded-lg border border-border bg-muted/20 p-4">
<div className="flex items-center gap-2">
<KeyRound className="size-5 text-primary" />
<h3 className="font-semibold">Generate New Identity</h3>
</div>
<p className="text-sm text-muted-foreground">
Create a brand new Nostr identity with a randomly generated
private key. Make sure to back up your key!
</p>
{generatedNsec && (
<div className="space-y-2">
<div className="flex items-start gap-2 rounded-md border border-yellow-500/50 bg-yellow-500/10 p-3 text-sm text-yellow-600 dark:text-yellow-400">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<div>
<p className="font-semibold">
Save your private key (nsec)
</p>
<p className="mt-1">
This is your ONLY copy. Store it somewhere safe. Anyone
with this key can control your identity.
</p>
</div>
</div>
<div className="rounded-md bg-background p-3 font-mono text-xs break-all">
{generatedNsec}
</div>
<Button
variant="outline"
size="sm"
onClick={copyGeneratedNsec}
className="w-full"
>
{copied ? (
<>
<Check className="mr-2 size-4" />
Copied to Clipboard
</>
) : (
<>
<Copy className="mr-2 size-4" />
Copy Private Key
</>
)}
</Button>
</div>
)}
<Button
onClick={generateIdentity}
disabled={loading}
className="w-full"
variant="default"
>
{loading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Generating...
</>
) : (
<>
<KeyRound className="mr-2 size-4" />
Generate New Identity
</>
)}
</Button>
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or import existing key
</span>
</div>
</div>
{/* Import nsec Section */}
<div className="space-y-3 rounded-lg border border-border bg-muted/20 p-4">
<div className="flex items-center gap-2">
<Shield className="size-5 text-orange-500" />
<h3 className="font-semibold">Import Private Key</h3>
</div>
{/* Security Warning */}
{!showNsecWarning ? (
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-md border border-orange-500/50 bg-orange-500/10 p-3 text-sm text-orange-600 dark:text-orange-400">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<div>
<p className="font-semibold">Security Warning</p>
<p className="mt-1">
Never paste your private key into websites you don't
trust. This key gives full control over your Nostr
identity.
</p>
</div>
</div>
<Button
variant="outline"
onClick={() => setShowNsecWarning(true)}
className="w-full"
>
I Understand, Continue
</Button>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Enter your private key in nsec or hex format.
</p>
<div className="space-y-2">
<label
htmlFor="nsec-input"
className="text-sm font-medium leading-none"
>
Private Key (nsec or hex)
</label>
<Input
id="nsec-input"
type="password"
placeholder="nsec1... or hex"
value={nsecInput}
onChange={(e) => setNsecInput(e.target.value)}
disabled={loading}
className="font-mono"
/>
</div>
<Button
onClick={loginWithNsec}
disabled={loading || !nsecInput.trim()}
className="w-full"
>
{loading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Importing...
</>
) : (
"Import Private Key"
)}
</Button>
</div>
)}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>