diff --git a/src/components/AuthModal.vue b/src/components/AuthModal.vue index f0600ac..911636d 100644 --- a/src/components/AuthModal.vue +++ b/src/components/AuthModal.vue @@ -64,31 +64,29 @@ - - +
+ or +
+ - + + Waiting for Amber... + +

Approve the request in Amber, then come back here

+ + +
+

Clipboard didn't work? Paste your public key:

+
+ + +
+
+ - +
-

- Approve the sign-in request in Amber, then tap below. -

- + Approve sign-in in Amber... +
+

Approve the signing request, then come back here

+ + +
+

Clipboard didn't work? Paste the signature:

+
+ + +
+
+ + +
+
+ + + + + Completing sign-in... +
+
+
{{ mode === 'register' ? 'Already have an account?' : "Don't have an account?" }} @@ -256,14 +297,14 @@ const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode) const errorMessage = ref(null) const isLoading = computed(() => authLoading.value || sovereignGenerating.value) -// NIP-07 extension detection — hide the button entirely when no extension is installed -const hasNostrExtension = ref(!!(window as any).nostr) - -// Amber three-phase login state -const amberPhase = ref<'idle' | 'waiting-pubkey' | 'waiting-signature'>('idle') +// Amber login state +const amberPhase = ref<'idle' | 'waiting-pubkey' | 'waiting-signature' | 'completing'>('idle') const amberPubkey = ref(null) -/** The exact unsigned NIP-98 event sent to Amber for signing (reused in Phase 3) */ const amberUnsignedEvent = ref(null) +const amberPasteInput = ref('') +const showPasteFallback = ref(false) +let amberFallbackTimer: ReturnType | null = null +let amberVisibilityHandler: (() => void) | null = null // ── Sovereign Identity Flow ────────────────────────────────────── type SovereignPhase = 'normal' | 'nah' | 'own-privacy' | 'generated' @@ -281,17 +322,14 @@ watch(() => props.isOpen, (open) => { sovereignDismissed.value = false generatedKeys.value = null errorMessage.value = null - amberPhase.value = 'idle' - amberUnsignedEvent.value = null + cancelAmber() } }) function closeModal() { emit('close') errorMessage.value = null - amberPhase.value = 'idle' - amberPubkey.value = null - amberUnsignedEvent.value = null + cancelAmber() sovereignPhase.value = 'normal' sovereignDismissed.value = false generatedKeys.value = null @@ -434,78 +472,126 @@ async function handleNostrLogin() { } /** - * Amber Login — Phase 1: Open Amber via Android intent to get the pubkey. + * Clean up Amber flow state, timers, and event listeners. + */ +function cancelAmber() { + amberPhase.value = 'idle' + amberPubkey.value = null + amberUnsignedEvent.value = null + amberPasteInput.value = '' + showPasteFallback.value = false + if (amberFallbackTimer) { + clearTimeout(amberFallbackTimer) + amberFallbackTimer = null + } + if (amberVisibilityHandler) { + document.removeEventListener('visibilitychange', amberVisibilityHandler) + amberVisibilityHandler = null + } +} + +/** + * Start listening for the user to return from Amber. + * On visibilitychange → visible, waits 500ms then tries to read + * clipboard. If clipboard read fails or is empty, shows paste fallback. * - * Amber copies the pubkey to the clipboard. Mobile browsers block - * automatic clipboard reads (navigator.clipboard.readText requires a - * user gesture), so we move to phase 2 where the user taps a button - * to trigger the read inside a real user-gesture event handler. + * @param onSuccess Called with clipboard text when auto-read succeeds. + */ +function listenForAmberReturn(onSuccess: (text: string) => void) { + // Show paste fallback after 8 seconds regardless + amberFallbackTimer = setTimeout(() => { + showPasteFallback.value = true + }, 8000) + + amberVisibilityHandler = () => { + if (document.visibilityState !== 'visible') return + + // User returned to browser — try clipboard after a short delay + // (same 500ms + 200ms pattern as AmberClipboardSigner) + setTimeout(async () => { + try { + if (!navigator.clipboard?.readText) { + showPasteFallback.value = true + return + } + const text = (await navigator.clipboard.readText()).trim() + if (!text) { + showPasteFallback.value = true + return + } + // Clipboard read succeeded — clean up listener and proceed + if (amberVisibilityHandler) { + document.removeEventListener('visibilitychange', amberVisibilityHandler) + amberVisibilityHandler = null + } + if (amberFallbackTimer) { + clearTimeout(amberFallbackTimer) + amberFallbackTimer = null + } + onSuccess(text) + } catch { + // Clipboard permission denied or other error — show paste input + showPasteFallback.value = true + } + }, 600) + } + + document.addEventListener('visibilitychange', amberVisibilityHandler) +} + +/** + * Amber Login — Step 1: Open Amber to get the public key. + * Sets up auto-detection via visibilitychange for when the user returns. */ function handleAmberOpen() { errorMessage.value = null + showPasteFallback.value = false + amberPasteInput.value = '' - // Check platform support const isAndroid = navigator.userAgent.includes('Android') if (!isAndroid) { errorMessage.value = 'Amber is only available on Android devices. Please use a Nostr browser extension instead.' return } - // Open Amber via the nostrsigner intent to get pubkey + // Open Amber via intent to get pubkey const intent = 'intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end' window.open(intent, '_blank') - // Switch to phase 2 — user will tap "Continue" after Amber approves amberPhase.value = 'waiting-pubkey' + + // Auto-detect: when user returns, try to read pubkey from clipboard + listenForAmberReturn((clipboardText) => { + processPubkey(clipboardText) + }) } /** - * Read a pubkey from clipboard text. Handles hex, npub, and nprofile formats. + * Decode a pubkey from text. Handles hex, npub, and nprofile formats. */ -async function decodePubkeyFromClipboard(clipboardText: string): Promise { - if (/^[0-9a-f]{64}$/i.test(clipboardText)) { - return clipboardText - } - if (clipboardText.startsWith('npub') || clipboardText.startsWith('nprofile')) { +async function decodePubkeyText(text: string): Promise { + if (/^[0-9a-f]{64}$/i.test(text)) return text + if (text.startsWith('npub') || text.startsWith('nprofile')) { const { decodeProfilePointer } = await import('applesauce-core/helpers') - const decoded = decodeProfilePointer(clipboardText) - if (!decoded?.pubkey) throw new Error('Could not decode npub from clipboard') + const decoded = decodeProfilePointer(text) + if (!decoded?.pubkey) throw new Error('Could not decode npub') return decoded.pubkey } - throw new Error('Clipboard does not contain a valid Nostr public key. Got: ' + clipboardText.slice(0, 20) + '...') + throw new Error('Not a valid public key. Expected hex or npub format.') } /** - * Amber Login — Phase 2: Read the pubkey from the clipboard, then - * construct the NIP-98 event and open Amber again to sign it. - * - * This runs inside a click handler (user gesture) so - * navigator.clipboard.readText() is permitted by the browser. + * Process a pubkey (from auto-read or paste), then open Amber to sign NIP-98. */ -async function handleAmberPubkeyRead() { +async function processPubkey(text: string) { errorMessage.value = null - try { - if (!navigator.clipboard?.readText) { - throw new Error('Clipboard API not available') - } - const clipboardText = (await navigator.clipboard.readText()).trim() - - if (!clipboardText) { - throw new Error('Clipboard is empty. Please try again — make sure you approved the request in Amber.') - } - - const pubkey = await decodePubkeyFromClipboard(clipboardText) + const pubkey = await decodePubkeyText(text) amberPubkey.value = pubkey + showPasteFallback.value = false + amberPasteInput.value = '' - // NOTE: We do NOT register the Amber account in accountManager here. - // That happens in Phase 3 after backend login succeeds, to avoid a - // split state where accountManager.active is set but authStore - // isn't authenticated (which confuses guards and the UI). - - // Build the unsigned NIP-98 event that the backend expects. - // Store it so Phase 3 can reuse the exact same event data - // (same created_at) when combining with the returned signature. + // Build the unsigned NIP-98 event const sessionUrl = authService.getNostrSessionUrl() const unsignedEvent = { kind: 27235, @@ -519,94 +605,69 @@ async function handleAmberPubkeyRead() { } amberUnsignedEvent.value = unsignedEvent - // Open Amber to sign this NIP-98 event + // Open Amber to sign this event const eventJson = JSON.stringify(unsignedEvent) - const intent = `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=event;S.type=sign_event;S.event=${encodeURIComponent(eventJson)};end` + const intent = `intent:${encodeURIComponent(eventJson)}#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=sign_event;end` window.open(intent, '_blank') - // Move to phase 3 — user will tap "Complete Sign-in" after Amber signs amberPhase.value = 'waiting-signature' - } catch (error: any) { - console.error('Amber pubkey read failed:', error) - handleAmberError(error) + + // Auto-detect: when user returns, try to read signature from clipboard + listenForAmberReturn((clipboardText) => { + processSignature(clipboardText) + }) + } catch (err: any) { + errorMessage.value = err.message || 'Invalid public key. Please try again.' + showPasteFallback.value = true } } /** - * Amber Login — Phase 3: Read the signed NIP-98 event from the - * clipboard and complete authentication. - * - * This runs inside a click handler (user gesture) so - * navigator.clipboard.readText() is permitted by the browser. + * Manual paste submit for pubkey. */ -async function handleAmberSignComplete() { +function handleAmberPasteSubmit() { + const text = amberPasteInput.value.trim() + if (!text) return + processPubkey(text) +} + +/** + * Process a signature (from auto-read or paste) and complete login. + */ +async function processSignature(text: string) { errorMessage.value = null + const pubkey = amberPubkey.value + if (!pubkey) { errorMessage.value = 'Lost pubkey. Please start over.'; cancelAmber(); return } + const storedEvent = amberUnsignedEvent.value + if (!storedEvent) { errorMessage.value = 'Lost signing request. Please start over.'; cancelAmber(); return } + + amberPhase.value = 'completing' + try { - if (!navigator.clipboard?.readText) { - throw new Error('Clipboard API not available') - } - const clipboardText = (await navigator.clipboard.readText()).trim() - - if (!clipboardText) { - throw new Error('Clipboard is empty. Make sure you approved the signing request in Amber.') - } - - const pubkey = amberPubkey.value - if (!pubkey) throw new Error('Lost track of the pubkey. Please start over.') - - const storedEvent = amberUnsignedEvent.value - if (!storedEvent) throw new Error('Lost track of the signing request. Please start over.') - - // Amber may return different formats depending on version: - // 1. Full signed event JSON (starts with '{') - // 2. Just the hex signature (64-byte Schnorr = 128 hex chars) - // 3. Hex string of varying length (some Amber versions) let authHeader: string - // Try 1: Full signed event JSON - if (clipboardText.startsWith('{')) { - try { - const signedEvent = JSON.parse(clipboardText) - if (signedEvent.sig) { - authHeader = `Nostr ${btoa(JSON.stringify(signedEvent))}` - } else { - throw new Error('missing sig') - } - } catch { - throw new Error('Clipboard contains JSON but it is not a valid signed event. Please try again.') - } + // Full signed event JSON + if (text.startsWith('{')) { + const signedEvent = JSON.parse(text) + if (!signedEvent.sig) throw new Error('Signed event missing signature') + authHeader = `Nostr ${btoa(JSON.stringify(signedEvent))}` } - // Try 2: Hex string (signature) — combine with the stored unsigned event - else if (/^[0-9a-f]+$/i.test(clipboardText)) { - const sig = clipboardText - - // Reconstruct the full event using the EXACT unsigned event we - // sent to Amber (same created_at, tags, etc.) + the signature - const event = { - ...storedEvent, - sig, - } - - // Calculate the event ID + // Hex signature — combine with stored unsigned event + else if (/^[0-9a-f]+$/i.test(text)) { + const event = { ...storedEvent, sig: text } const { getEventHash } = await import('nostr-tools/pure') event.id = getEventHash(event as any) - authHeader = `Nostr ${btoa(JSON.stringify(event))}` } - // Nothing we recognise else { - throw new Error( - 'Could not read the signed event from Amber. ' - + 'Please make sure you approved the request in Amber, then try again.' - ) + throw new Error('Unrecognised signature format from Amber.') } - // Authenticate with the pre-signed NIP-98 header (bypasses signer.signEvent) + // Authenticate with the pre-signed NIP-98 header await loginWithNostr(pubkey, 'amber-nip55', {}, authHeader) - // Backend login succeeded — NOW register the Amber account in - // accountManager so the rest of the UI reflects the logged-in state. + // Register the Amber account in accountManager try { const { AmberClipboardSigner, AmberClipboardAccount } = await import('../lib/accounts') const signer = new AmberClipboardSigner() @@ -618,30 +679,24 @@ async function handleAmberSignComplete() { console.warn('[Amber] Account registration after login:', accountErr) } - amberPhase.value = 'idle' - amberPubkey.value = null - amberUnsignedEvent.value = null + cancelAmber() emit('success') closeModal() - } catch (error: any) { - console.error('Amber sign-in failed:', error) - handleAmberError(error) + } catch (err: any) { + console.error('Amber sign-in failed:', err) + errorMessage.value = err.message || 'Failed to complete Amber sign-in. Please try again.' + amberPhase.value = 'waiting-signature' + showPasteFallback.value = true } } /** - * Shared error handler for Amber flow phases. + * Manual paste submit for signature. */ -function handleAmberError(error: any) { - if (error.message?.includes('Clipboard is empty')) { - errorMessage.value = 'Clipboard is empty. Open Amber, approve the request, then come back and tap the button.' - } else if (error.message?.includes('not available')) { - errorMessage.value = 'Clipboard access is not available. Try using Chrome on Android.' - } else if (error.message?.includes('permission')) { - errorMessage.value = 'Clipboard permission denied. Please allow clipboard access and try again.' - } else { - errorMessage.value = error.message || 'Failed to read from Amber. Please try again.' - } +function handleAmberSignPasteSubmit() { + const text = amberPasteInput.value.trim() + if (!text) return + processSignature(text) } // Declare window.nostr for TypeScript