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