An NWC connection string is a spend credential stored client-side. If a connection is granted an unlimited budget, a compromise could drain the wallet up to that limit. Add a prominent security notice in the "Connect NWC Wallet" dialog instructing users to set a small daily budget and scope the connection to pay_invoice only, and reminding them it can be revoked in their wallet. This caps the blast radius of the credential the app must hold. (The CSP referenced in the report was already added in the prior commit via public/_headers; storage of nsec/NWC strings in localStorage is inherent to client-side Nostr signing/payments and is mitigated by the XSS + CSP work.) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
419 lines
No EOL
14 KiB
TypeScript
419 lines
No EOL
14 KiB
TypeScript
import { useState, forwardRef } from 'react';
|
|
import { Wallet, Plus, Trash2, Zap, Globe, WalletMinimal, CheckCircle, X, ShieldAlert } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Drawer,
|
|
DrawerContent,
|
|
DrawerDescription,
|
|
DrawerHeader,
|
|
DrawerTitle,
|
|
DrawerTrigger,
|
|
DrawerClose,
|
|
} from '@/components/ui/drawer';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { useNWC } from '@/hooks/useNWCContext';
|
|
import { useWallet } from '@/hooks/useWallet';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
|
import type { NWCConnection, NWCInfo } from '@/hooks/useNWC';
|
|
import type { WebLNProvider } from "@webbtc/webln-types";
|
|
|
|
interface WalletModalProps {
|
|
children?: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
// Extracted AddWalletContent to prevent re-renders
|
|
const AddWalletContent = forwardRef<HTMLDivElement, {
|
|
alias: string;
|
|
setAlias: (value: string) => void;
|
|
connectionUri: string;
|
|
setConnectionUri: (value: string) => void;
|
|
}>(({ alias, setAlias, connectionUri, setConnectionUri }, ref) => (
|
|
<div className="space-y-4 px-4" ref={ref}>
|
|
<div>
|
|
<Label htmlFor="alias">Wallet Name (optional)</Label>
|
|
<Input
|
|
id="alias"
|
|
placeholder="My Lightning Wallet"
|
|
value={alias}
|
|
onChange={(e) => setAlias(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="connection-uri">Connection URI</Label>
|
|
<Textarea
|
|
id="connection-uri"
|
|
placeholder="nostr+walletconnect://..."
|
|
value={connectionUri}
|
|
onChange={(e) => setConnectionUri(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
{/* Security guidance: an NWC string is a spend credential. Encourage users
|
|
to cap it in their wallet so a compromised connection can't be drained. */}
|
|
<div className="flex gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
|
<ShieldAlert className="h-4 w-4 flex-shrink-0 text-amber-500 mt-0.5" />
|
|
<div className="space-y-1 text-xs text-amber-200/90">
|
|
<p className="font-semibold text-amber-300">Set a spending limit first</p>
|
|
<p>
|
|
This string lets SkateSpots pay Lightning invoices from your wallet. In
|
|
your wallet app, give this connection a small <strong>daily budget</strong>{' '}
|
|
(e.g. a few thousand sats) and enable only <strong>pay_invoice</strong>.
|
|
Never paste a connection with an unlimited budget. You can revoke it in
|
|
your wallet any time.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
));
|
|
AddWalletContent.displayName = 'AddWalletContent';
|
|
|
|
// Recommended wallets for NWC
|
|
const RECOMMENDED_WALLETS = [
|
|
{ name: 'Alby', url: 'https://getalby.com', type: 'extension' },
|
|
{ name: 'Minibits', url: 'https://www.minibits.cash', type: 'mobile' },
|
|
{ name: 'Zeus', url: 'https://zeusln.com', type: 'mobile' },
|
|
{ name: 'Coinos', url: 'https://coinos.io', type: 'web' },
|
|
];
|
|
|
|
// Extracted WalletContent to prevent re-renders
|
|
const WalletContent = forwardRef<HTMLDivElement, {
|
|
webln: WebLNProvider | null;
|
|
hasNWC: boolean;
|
|
connections: NWCConnection[];
|
|
connectionInfo: Record<string, NWCInfo>;
|
|
activeConnection: string | null;
|
|
handleSetActive: (cs: string) => void;
|
|
handleRemoveConnection: (cs: string) => void;
|
|
setAddDialogOpen: (open: boolean) => void;
|
|
}>(({
|
|
webln,
|
|
hasNWC,
|
|
connections,
|
|
connectionInfo,
|
|
activeConnection,
|
|
handleSetActive,
|
|
handleRemoveConnection,
|
|
setAddDialogOpen
|
|
}, ref) => (
|
|
<div className="space-y-4 sm:space-y-6 px-0 sm:px-4 pb-4" ref={ref}>
|
|
{/* NWC First - Primary Option */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="font-medium text-sm sm:text-base">Nostr Wallet Connect</h3>
|
|
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
Add Wallet
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Connected Wallets List */}
|
|
{connections.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{connections.map((connection) => {
|
|
const info = connectionInfo[connection.connectionString];
|
|
const isActive = activeConnection === connection.connectionString;
|
|
return (
|
|
<div key={connection.connectionString} className={`flex items-center justify-between p-3 border rounded-lg ${isActive ? 'ring-2 ring-primary bg-primary/5' : ''}`}>
|
|
<div className="flex items-center gap-3">
|
|
<WalletMinimal className="h-4 w-4 text-muted-foreground" />
|
|
<div>
|
|
<p className="text-sm font-medium">
|
|
{connection.alias || info?.alias || 'Lightning Wallet'}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
NWC Connection
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isActive && <CheckCircle className="h-4 w-4 text-green-600" />}
|
|
{!isActive && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleSetActive(connection.connectionString)}
|
|
>
|
|
<Zap className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleRemoveConnection(connection.connectionString)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-4 px-3 border border-dashed rounded-lg">
|
|
<WalletMinimal className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground mb-3">
|
|
Paste your NWC connection string to send zaps
|
|
</p>
|
|
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
Connect Wallet
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* WebLN - Secondary Option */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between p-2 sm:p-3 border rounded-lg">
|
|
<div className="flex items-center gap-2 sm:gap-3">
|
|
<Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="text-xs sm:text-sm font-medium">Browser Extension (WebLN)</p>
|
|
<p className="text-[10px] sm:text-xs text-muted-foreground">Auto-detected if installed</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
|
{webln && <CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-600" />}
|
|
<Badge variant={webln ? "default" : "secondary"} className="text-[10px] sm:text-xs">
|
|
{webln ? "Ready" : "Not Found"}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recommended Wallets - Show when no wallet connected */}
|
|
{!webln && connections.length === 0 && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-medium text-muted-foreground">Get NWC from:</h4>
|
|
<div className="grid gap-2">
|
|
{RECOMMENDED_WALLETS.map((wallet) => (
|
|
<a
|
|
key={wallet.name}
|
|
href={wallet.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-between p-2 border rounded-lg hover:bg-accent transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Zap className="h-4 w-4 text-yellow-500" />
|
|
<span className="text-sm font-medium">{wallet.name}</span>
|
|
</div>
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{wallet.type}
|
|
</Badge>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
));
|
|
WalletContent.displayName = 'WalletContent';
|
|
|
|
export function WalletModal({ children, className }: WalletModalProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
|
const [connectionUri, setConnectionUri] = useState('');
|
|
const [alias, setAlias] = useState('');
|
|
const [isConnecting, setIsConnecting] = useState(false);
|
|
const isMobile = useIsMobile();
|
|
|
|
const {
|
|
connections,
|
|
activeConnection,
|
|
connectionInfo,
|
|
addConnection,
|
|
removeConnection,
|
|
setActiveConnection
|
|
} = useNWC();
|
|
|
|
const { webln } = useWallet();
|
|
|
|
const hasNWC = connections.length > 0 && connections.some(c => c.isConnected);
|
|
const { toast } = useToast();
|
|
|
|
const handleAddConnection = async () => {
|
|
if (!connectionUri.trim()) {
|
|
toast({
|
|
title: 'Connection URI required',
|
|
description: 'Please enter a valid NWC connection URI.',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsConnecting(true);
|
|
try {
|
|
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
|
|
if (success) {
|
|
setConnectionUri('');
|
|
setAlias('');
|
|
setAddDialogOpen(false);
|
|
}
|
|
} finally {
|
|
setIsConnecting(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveConnection = (connectionString: string) => {
|
|
removeConnection(connectionString);
|
|
};
|
|
|
|
const handleSetActive = (connectionString: string) => {
|
|
setActiveConnection(connectionString);
|
|
toast({
|
|
title: 'Active wallet changed',
|
|
description: 'The selected wallet is now active for zaps.',
|
|
});
|
|
};
|
|
|
|
const walletContentProps = {
|
|
webln,
|
|
hasNWC,
|
|
connections,
|
|
connectionInfo,
|
|
activeConnection,
|
|
handleSetActive,
|
|
handleRemoveConnection,
|
|
setAddDialogOpen,
|
|
};
|
|
|
|
const addWalletDialog = (
|
|
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
|
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] p-4 sm:p-6">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">Connect NWC Wallet</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
Enter your connection string from a compatible wallet.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<AddWalletContent
|
|
alias={alias}
|
|
setAlias={setAlias}
|
|
connectionUri={connectionUri}
|
|
setConnectionUri={setConnectionUri}
|
|
/>
|
|
<DialogFooter className="px-0 sm:px-4">
|
|
<Button
|
|
onClick={handleAddConnection}
|
|
disabled={isConnecting || !connectionUri.trim()}
|
|
className="w-full"
|
|
>
|
|
{isConnecting ? 'Connecting...' : 'Connect'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<>
|
|
<Drawer open={open} onOpenChange={setOpen}>
|
|
<DrawerTrigger asChild>
|
|
{children || (
|
|
<Button variant="outline" size="sm" className={className}>
|
|
<Wallet className="h-4 w-4 mr-2" />
|
|
Wallet Settings
|
|
</Button>
|
|
)}
|
|
</DrawerTrigger>
|
|
<DrawerContent className="h-full">
|
|
<DrawerHeader className="text-center relative">
|
|
<DrawerClose asChild>
|
|
<Button variant="ghost" size="sm" className="absolute right-4 top-4">
|
|
<X className="h-4 w-4" />
|
|
<span className="sr-only">Close</span>
|
|
</Button>
|
|
</DrawerClose>
|
|
<DrawerTitle className="flex items-center justify-center gap-2 pt-2">
|
|
<Wallet className="h-5 w-5" />
|
|
Lightning Wallet
|
|
</DrawerTitle>
|
|
<DrawerDescription>
|
|
Connect your lightning wallet to send zaps instantly.
|
|
</DrawerDescription>
|
|
</DrawerHeader>
|
|
<div className="overflow-y-auto">
|
|
<WalletContent {...walletContentProps} />
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
{/* Render Add Wallet as a separate Drawer for mobile */}
|
|
<Drawer open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
|
<DrawerContent>
|
|
<DrawerHeader>
|
|
<DrawerTitle>Connect NWC Wallet</DrawerTitle>
|
|
<DrawerDescription>
|
|
Enter your connection string from a compatible wallet.
|
|
</DrawerDescription>
|
|
</DrawerHeader>
|
|
<AddWalletContent
|
|
alias={alias}
|
|
setAlias={setAlias}
|
|
connectionUri={connectionUri}
|
|
setConnectionUri={setConnectionUri}
|
|
/>
|
|
<div className="p-4">
|
|
<Button
|
|
onClick={handleAddConnection}
|
|
disabled={isConnecting || !connectionUri.trim()}
|
|
className="w-full"
|
|
>
|
|
{isConnecting ? 'Connecting...' : 'Connect'}
|
|
</Button>
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
{children || (
|
|
<Button variant="outline" size="sm" className={className}>
|
|
<Wallet className="h-4 w-4 mr-2" />
|
|
Wallet Settings
|
|
</Button>
|
|
)}
|
|
</DialogTrigger>
|
|
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[500px] max-h-[85vh] max-h-[85dvh] overflow-y-auto p-4 sm:p-6">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
<Wallet className="h-5 w-5" />
|
|
Lightning Wallet
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
Connect your lightning wallet to send zaps instantly.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<WalletContent {...walletContentProps} />
|
|
</DialogContent>
|
|
</Dialog>
|
|
{addWalletDialog}
|
|
</>
|
|
);
|
|
} |