mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user