diff --git a/frontend/src/screens/CreateProfileModal.tsx b/frontend/src/screens/CreateProfileModal.tsx index f6ca69b..457f471 100644 --- a/frontend/src/screens/CreateProfileModal.tsx +++ b/frontend/src/screens/CreateProfileModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'; +import QRCode from 'qrcode'; import { useApp } from '../state/AppProvider'; import { shortenNpub } from '../lib/format'; import { Button } from '../components/Button'; @@ -11,14 +12,15 @@ interface CreateProfileModalProps { onClose: () => void; } -type Phase = 'form' | 'creating' | 'success'; +type Phase = 'choice' | 'local' | 'creating' | 'success' | 'pairing' | 'paired'; export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) { - const { state, createProfile } = useApp(); + const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp(); const [label, setLabel] = useState(''); - const [phase, setPhase] = useState('form'); + const [phase, setPhase] = useState('choice'); const [error, setError] = useState(null); const [createdNpub, setCreatedNpub] = useState(null); + const [pairingQr, setPairingQr] = useState(null); const [errorId] = useState(() => `create-profile-error-${Math.random().toString(36).slice(2)}`); const inputRef = useRef(null); const shorten = state?.settings.shorten_npub ?? true; @@ -26,15 +28,88 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) { useEffect(() => { if (open) { setLabel(''); - setPhase('form'); + setPhase('choice'); setError(null); setCreatedNpub(null); - // Let the modal mount before focusing. + setPairingQr(null); + return undefined; + } + return undefined; + }, [open]); + + // Let the local-form input take focus once that step mounts. + useEffect(() => { + if (phase === 'local') { const frame = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(frame); } return undefined; - }, [open]); + }, [phase]); + + // Pairing is a server-side handshake with no push channel: poll the + // signer status while this modal sits on the QR, exactly like the Signer + // Mode screen does. Connected → show success; an error → show it (the + // backend also clears pairing_uri, so the QR view exits on failure). + useEffect(() => { + if (phase !== 'pairing') return undefined; + let cancelled = false; + const tick = async () => { + try { + const status = await nip46Status(); + if (cancelled) return; + if (status.connected) { + await refresh().catch(() => {}); + if (!cancelled) setPhase('paired'); + } else if (status.error) { + if (!cancelled) setError(status.error); + } + } catch { + // Transient IPC errors are fine; the next poll retries. + } + }; + void tick(); + const timer = setInterval(() => void tick(), 2000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [phase, nip46Status, refresh]); + + const [livePairingUri, setLivePairingUri] = useState(null); + useEffect(() => { + if (phase !== 'pairing' || livePairingUri) return undefined; + let cancelled = false; + void nip46Status() + .then((status) => { + if (!cancelled && status.pairing_uri) setLivePairingUri(status.pairing_uri); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [phase, livePairingUri, nip46Status]); + + useEffect(() => { + if (!livePairingUri) { + setPairingQr(null); + return; + } + let cancelled = false; + QRCode.toDataURL(livePairingUri, { + width: 480, + margin: 2, + errorCorrectionLevel: 'M', + }) + .then((url) => { + if (!cancelled) setPairingQr(url); + }) + .catch(() => { + if (!cancelled) setError('Could not render the pairing QR code.'); + }); + return () => { + cancelled = true; + }; + }, [livePairingUri]); const canSubmit = label.trim().length > 0 && phase !== 'creating'; @@ -50,13 +125,151 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) { setCreatedNpub(summary.npub); setPhase('success'); } catch (err) { - setPhase('form'); + setPhase('local'); setError(err instanceof Error ? err.message : String(err)); } }; + const startPairing = async () => { + setError(null); + try { + const status = await nip46PairStart(label.trim() || 'Remote Signer'); + if (status.pairing_uri) setLivePairingUri(status.pairing_uri); + setPhase('pairing'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + // Leaving the QR view mid-pairing aborts the in-flight pairing; nothing + // was persisted yet, so teardown is safe at any point (same as Signer + // Mode's "Cancel pairing"). + const cancelPairing = async () => { + setLivePairingUri(null); + setPairingQr(null); + setPhase('choice'); + try { + await nip46Disconnect(); + } catch { + // Best-effort abort; a dead pairing attempt expires on its own. + } + }; + + const handleClose = () => { + if (phase === 'pairing') { + void cancelPairing(); + } + onClose(); + }; + + if (phase === 'choice') { + return ( + +
+

How do you want this profile to sign?

+
+ {error && {error}} +
+ +

+ Show a QR code to Amber on your phone — your keys stay on the phone, and every signature + is approved there. +

+ +

+ A brand-new local identity whose private key lives in this app's vault. +

+
+
+ ); + } + + if (phase === 'pairing') { + return ( + +
+

+ Scan this code with Amber (or any NIP-46 signer) and approve the + connection. +

+ {pairingQr ? ( + Pairing QR code + ) : ( +

+ Preparing the pairing code… +

+ )} +

+ Waiting for the signer to scan… the profile appears automatically once approved. The + code expires after a few minutes. +

+ {error && {error}} +
+ +
+ {livePairingUri && ( +
+ Or copy the pairing link + + {livePairingUri} + +
+ )} +
+
+ ); + } + + if (phase === 'paired') { + return ( + +
+ +

Amber is now your signer!

+

+ The connected account was added as a profile and selected. Every signature will ask for + approval in Amber — nothing to install here, and the connection comes back automatically + after restarts. +

+
+ +
+
+
+ ); + } + return ( - + {phase === 'success' && createdNpub ? (
-