BunkerLogin.tsx raw
1 import QrScannerModal from '@/components/QrScannerModal'
2 import { Button } from '@/components/ui/button'
3 import { Input } from '@/components/ui/input'
4 import { Label } from '@/components/ui/label'
5 import { useNostr } from '@/providers/NostrProvider'
6 import { BunkerSigner } from '@/providers/NostrProvider/bunker.signer'
7 import { ArrowLeft, Loader2, QrCode, Server, Copy, Check, ScanLine } from 'lucide-react'
8 import { useState, useEffect } from 'react'
9 import { useTranslation } from 'react-i18next'
10 import QRCode from 'qrcode'
11
12 // No default bunker relay - user must configure to protect privacy
13 // Popular options: wss://relay.nsec.app, wss://relay.damus.io, or user's own relay
14 const DEFAULT_BUNKER_RELAY = ''
15
16 export default function BunkerLogin({
17 back,
18 onLoginSuccess
19 }: {
20 back: () => void
21 onLoginSuccess: () => void
22 }) {
23 const { t } = useTranslation()
24 const { bunkerLoginWithSigner, bunkerLogin } = useNostr()
25 const [mode, setMode] = useState<'choose' | 'scan' | 'paste'>('choose')
26 const [bunkerUrl, setBunkerUrl] = useState('')
27 const [relayUrl, setRelayUrl] = useState(DEFAULT_BUNKER_RELAY)
28 const [loading, setLoading] = useState(false)
29 const [error, setError] = useState<string | null>(null)
30 const [connectUrl, setConnectUrl] = useState<string | null>(null)
31 const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
32 const [copied, setCopied] = useState(false)
33 const [showScanner, setShowScanner] = useState(false)
34
35 // Generate QR code when in scan mode
36 useEffect(() => {
37 if (mode !== 'scan') return
38
39 let cancelled = false
40
41 const startConnection = async () => {
42 setLoading(true)
43 setError(null)
44
45 // Validate relay URL is configured
46 if (!relayUrl.trim()) {
47 setError(t('Relay URL is required - enter a relay to use for bunker connection'))
48 setLoading(false)
49 return
50 }
51
52 try {
53 const { connectUrl, signer: signerPromise } = await BunkerSigner.awaitSignerConnection(
54 relayUrl,
55 undefined,
56 120000 // 2 minute timeout
57 )
58
59 if (cancelled) return
60
61 setConnectUrl(connectUrl)
62
63 // Generate QR code
64 const qr = await QRCode.toDataURL(connectUrl, {
65 width: 256,
66 margin: 2,
67 color: { dark: '#000000', light: '#ffffff' }
68 })
69 setQrDataUrl(qr)
70 setLoading(false)
71
72 // Wait for signer to connect
73 const signer = await signerPromise
74
75 if (cancelled) {
76 signer.disconnect()
77 return
78 }
79
80 // Get the user's pubkey from the signer
81 const pubkey = await signer.getPublicKey()
82
83 // Complete login
84 await bunkerLoginWithSigner(signer, pubkey)
85 onLoginSuccess()
86 } catch (err) {
87 if (!cancelled) {
88 setError((err as Error).message)
89 setLoading(false)
90 }
91 }
92 }
93
94 startConnection()
95
96 return () => {
97 cancelled = true
98 }
99 }, [mode, relayUrl, bunkerLoginWithSigner, onLoginSuccess])
100
101 const handleScan = (result: string) => {
102 setBunkerUrl(result)
103 setError(null)
104 }
105
106 const handlePasteSubmit = async (e: React.FormEvent) => {
107 e.preventDefault()
108 if (!bunkerUrl.trim()) {
109 setError(t('Please enter a bunker URL'))
110 return
111 }
112
113 if (!bunkerUrl.startsWith('bunker://')) {
114 setError(t('Invalid bunker URL format. Must start with bunker://'))
115 return
116 }
117
118 setLoading(true)
119 setError(null)
120
121 try {
122 // Use the existing bunkerLogin flow for bunker:// URLs
123 await bunkerLogin(bunkerUrl.trim())
124 onLoginSuccess()
125 } catch (err) {
126 setError((err as Error).message)
127 } finally {
128 setLoading(false)
129 }
130 }
131
132 const copyToClipboard = async () => {
133 if (connectUrl) {
134 await navigator.clipboard.writeText(connectUrl)
135 setCopied(true)
136 setTimeout(() => setCopied(false), 2000)
137 }
138 }
139
140 if (mode === 'choose') {
141 return (
142 <div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
143 <div className="flex items-center gap-2">
144 <Button size="icon" variant="ghost" className="rounded-full" onClick={back}>
145 <ArrowLeft className="size-4" />
146 </Button>
147 <div className="flex items-center gap-2">
148 <Server className="size-5" />
149 <span className="font-semibold">{t('Login with Bunker')}</span>
150 </div>
151 </div>
152
153 <div className="space-y-3">
154 <Button
155 variant="outline"
156 className="w-full justify-start gap-3 h-auto py-4"
157 onClick={() => setMode('scan')}
158 >
159 <QrCode className="size-6" />
160 <div className="text-left">
161 <div className="font-medium">{t('Show QR Code')}</div>
162 <div className="text-xs text-muted-foreground">
163 {t('Scan with Amber or another NIP-46 signer')}
164 </div>
165 </div>
166 </Button>
167
168 <Button
169 variant="outline"
170 className="w-full justify-start gap-3 h-auto py-4"
171 onClick={() => setMode('paste')}
172 >
173 <Server className="size-6" />
174 <div className="text-left">
175 <div className="font-medium">{t('Paste Bunker URL')}</div>
176 <div className="text-xs text-muted-foreground">
177 {t('Enter a bunker:// URL from your signer')}
178 </div>
179 </div>
180 </Button>
181 </div>
182
183 <div className="text-xs text-muted-foreground space-y-2 pt-2">
184 <p>
185 <strong>{t('What is a bunker?')}</strong>
186 </p>
187 <p>
188 {t(
189 'A bunker (NIP-46) is a remote signing service that keeps your private key secure while allowing you to sign Nostr events. Your key never leaves the bunker.'
190 )}
191 </p>
192 </div>
193 </div>
194 )
195 }
196
197 if (mode === 'scan') {
198 return (
199 <div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
200 <div className="flex items-center gap-2">
201 <Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
202 <ArrowLeft className="size-4" />
203 </Button>
204 <div className="flex items-center gap-2">
205 <QrCode className="size-5" />
206 <span className="font-semibold">{t('Scan with Signer')}</span>
207 </div>
208 </div>
209
210 <div className="space-y-4">
211 <div className="space-y-2">
212 <Label htmlFor="relayUrl">{t('Relay URL')}</Label>
213 <Input
214 id="relayUrl"
215 type="text"
216 placeholder="wss://relay.nsec.app"
217 value={relayUrl}
218 onChange={(e) => setRelayUrl(e.target.value)}
219 disabled={loading || !!qrDataUrl}
220 className="font-mono text-sm"
221 />
222 <p className="text-xs text-muted-foreground">
223 {t('Enter a relay URL for bunker connection (e.g., wss://relay.nsec.app)')}
224 </p>
225 </div>
226
227 {loading && !qrDataUrl && (
228 <div className="flex items-center justify-center py-8">
229 <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
230 </div>
231 )}
232
233 {qrDataUrl && (
234 <div className="flex flex-col items-center gap-4">
235 <div
236 className="relative cursor-pointer rounded-lg overflow-hidden"
237 onClick={copyToClipboard}
238 title={t('Click to copy URL')}
239 >
240 <img src={qrDataUrl} alt="Bunker QR Code" className="w-64 h-64" />
241 <div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity">
242 {copied ? (
243 <Check className="size-8 text-white" />
244 ) : (
245 <Copy className="size-8 text-white" />
246 )}
247 </div>
248 </div>
249
250 <p className="text-sm text-muted-foreground text-center">
251 {t('Scan this QR code with Amber or your NIP-46 signer')}
252 </p>
253
254 <div className="flex items-center gap-2">
255 <Loader2 className="h-4 w-4 animate-spin" />
256 <span className="text-sm text-muted-foreground">{t('Waiting for connection...')}</span>
257 </div>
258
259 {connectUrl && (
260 <div className="w-full">
261 <Label className="text-xs text-muted-foreground">{t('Connection URL')}</Label>
262 <div className="flex gap-2 mt-1">
263 <Input
264 value={connectUrl}
265 readOnly
266 className="font-mono text-xs"
267 />
268 <Button size="icon" variant="outline" onClick={copyToClipboard}>
269 {copied ? <Check className="size-4" /> : <Copy className="size-4" />}
270 </Button>
271 </div>
272 </div>
273 )}
274 </div>
275 )}
276
277 {error && <div className="text-sm text-destructive text-center">{error}</div>}
278 </div>
279 </div>
280 )
281 }
282
283 // Paste mode
284 return (
285 <>
286 {showScanner && (
287 <QrScannerModal onScan={handleScan} onClose={() => setShowScanner(false)} />
288 )}
289 <div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
290 <div className="flex items-center gap-2">
291 <Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
292 <ArrowLeft className="size-4" />
293 </Button>
294 <div className="flex items-center gap-2">
295 <Server className="size-5" />
296 <span className="font-semibold">{t('Paste Bunker URL')}</span>
297 </div>
298 </div>
299
300 <form onSubmit={handlePasteSubmit} className="space-y-4">
301 <div className="space-y-2">
302 <Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
303 <div className="flex gap-2">
304 <Input
305 id="bunkerUrl"
306 type="text"
307 placeholder="bunker://pubkey?relay=wss://..."
308 value={bunkerUrl}
309 onChange={(e) => setBunkerUrl(e.target.value)}
310 disabled={loading}
311 className="font-mono text-sm"
312 />
313 <Button
314 type="button"
315 variant="outline"
316 size="icon"
317 onClick={() => setShowScanner(true)}
318 disabled={loading}
319 title={t('Scan QR code')}
320 >
321 <ScanLine className="h-4 w-4" />
322 </Button>
323 </div>
324 <p className="text-xs text-muted-foreground">
325 {t(
326 'Enter the bunker connection URL. This is typically provided by your signing device or service.'
327 )}
328 </p>
329 </div>
330
331 {error && <div className="text-sm text-destructive">{error}</div>}
332
333 <Button type="submit" className="w-full" disabled={loading || !bunkerUrl.trim()}>
334 {loading ? (
335 <>
336 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
337 {t('Connecting...')}
338 </>
339 ) : (
340 t('Connect to Bunker')
341 )}
342 </Button>
343 </form>
344 </div>
345 </>
346 )
347 }
348