From e10b0a0406e8d315c2e8a63411015631a8a77c5f Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 14 Feb 2026 13:07:52 +0000 Subject: [PATCH] refactor: simplify Nostr login button and improve Amber login flow - Removed conditional rendering for the Nostr login button, ensuring it is always displayed when the extension is available. - Enhanced the Amber login flow with clearer messaging during the waiting phases for pubkey and signature. - Added paste fallback options for users in case clipboard functionality fails, improving user experience during the login process. These changes streamline the authentication experience and provide better feedback to users during the login phases. --- src/components/AuthModal.vue | 427 ++++++++++++++++++++--------------- 1 file changed, 241 insertions(+), 186 deletions(-) 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