From 6bb95fd0047be2008c1d4cffeca84847720797fb Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Feb 2026 22:23:34 +0000 Subject: [PATCH] fix: Amber login on mobile with two-phase clipboard flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile browsers block navigator.clipboard.readText() unless called inside a user gesture (tap/click). The old flow relied on the visibilitychange event to auto-read the clipboard when the user returned from Amber, which silently failed. New flow: 1. User taps "Sign in with Amber" → opens Amber via Android intent 2. User approves in Amber → pubkey copied to clipboard 3. User returns to browser → sees "Complete Sign-in" button 4. User taps "Complete Sign-in" → clipboard read succeeds (user gesture) 5. Pubkey decoded, account registered, backend session created Also handles npub/nprofile decoding and provides clear error messages for empty clipboard, missing permissions, and non-Android devices. Co-authored-by: Cursor --- docker-compose.yml | 2 +- src/components/AuthModal.vue | 137 ++++++++++++++++++++++++++++++----- 2 files changed, 118 insertions(+), 21 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a114c15..c994d6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: context: . dockerfile: Dockerfile args: - CACHEBUST: "16" + CACHEBUST: "17" VITE_USE_MOCK_DATA: "false" VITE_CONTENT_ORIGIN: ${FRONTEND_URL} VITE_INDEEHUB_API_URL: /api diff --git a/src/components/AuthModal.vue b/src/components/AuthModal.vue index 0ca50e4..1b6250d 100644 --- a/src/components/AuthModal.vue +++ b/src/components/AuthModal.vue @@ -103,13 +103,14 @@ Sign in with Nostr Extension - + + + +
+

+ Approved in Amber? Tap below to complete sign-in. +

+ + +
+
{{ mode === 'login' ? "Don't have an account?" : "Already have an account?" }} @@ -156,7 +181,7 @@ const props = withDefaults(defineProps(), { const emit = defineEmits() const { login, loginWithNostr, register, isLoading: authLoading } = useAuth() -const { loginWithExtension, loginWithAmber } = useAccounts() +const { loginWithExtension } = useAccounts() const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode) const formData = ref({ @@ -167,11 +192,15 @@ const formData = ref({ const errorMessage = ref(null) const isLoading = computed(() => authLoading.value) +// Amber two-phase login state +const amberPhase = ref<'idle' | 'waiting'>('idle') + function closeModal() { emit('close') - // Reset form + // Reset form and Amber state formData.value = { email: '', password: '', legalName: '' } errorMessage.value = null + amberPhase.value = 'idle' } function toggleMode() { @@ -229,20 +258,74 @@ async function handleNostrLogin() { } /** - * Login with Amber (NIP-55 Android Signer) - * Uses the AmberClipboardSigner from applesauce-signers which - * handles the Android intent flow and clipboard-based result reading. - * Amber copies the pubkey to clipboard, and when the user returns - * to the browser the signer reads it automatically. + * Amber Login — Phase 1: Open Amber via Android intent. + * + * 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. */ -async function handleAmberLogin() { +function handleAmberOpen() { + errorMessage.value = null + + // 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 + 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 "Complete Sign-in" after Amber approves + amberPhase.value = 'waiting' +} + +/** + * Amber Login — Phase 2: Read the pubkey from the clipboard. + * + * This runs inside a click handler (user gesture) so + * navigator.clipboard.readText() is permitted by the browser. + */ +async function handleAmberComplete() { errorMessage.value = null try { - // Get the pubkey from Amber (opens intent, reads clipboard on return) - const pubkey = await loginWithAmber() + // Read pubkey from clipboard (allowed because this is a user gesture) + if (!navigator.clipboard?.readText) { + throw new Error('Clipboard API not available') + } + const clipboardText = (await navigator.clipboard.readText()).trim() - // Create auth session with the pubkey from Amber + if (!clipboardText) { + throw new Error('Clipboard is empty. Please try again — make sure you approved the request in Amber.') + } + + // Decode the pubkey (Amber may return hex, npub, or nprofile) + let pubkey: string + if (/^[0-9a-f]{64}$/i.test(clipboardText)) { + pubkey = clipboardText + } else if (clipboardText.startsWith('npub') || clipboardText.startsWith('nprofile')) { + // Decode using nostr-tools or applesauce helpers + const { decodeProfilePointer } = await import('applesauce-core/helpers') + const decoded = decodeProfilePointer(clipboardText) + if (!decoded?.pubkey) throw new Error('Could not decode npub from clipboard') + pubkey = decoded.pubkey + } else { + throw new Error('Clipboard does not contain a valid Nostr public key. Got: ' + clipboardText.slice(0, 20) + '...') + } + + // Register the Amber account in the account manager + const { AmberClipboardSigner, AmberClipboardAccount, accountManager } = await import('../lib/accounts') + const signer = new AmberClipboardSigner() + signer.pubkey = pubkey + const account = new AmberClipboardAccount(pubkey, signer) + accountManager.addAccount(account) + accountManager.setActive(account) + + // Create backend auth session await loginWithNostr(pubkey, 'amber-nip55', { kind: 27235, created_at: Math.floor(Date.now() / 1000), @@ -254,17 +337,20 @@ async function handleAmberLogin() { pubkey, }) + amberPhase.value = 'idle' emit('success') closeModal() } catch (error: any) { - console.error('Amber login failed:', error) + console.error('Amber clipboard read failed:', error) - if (error.message?.includes('non-Android')) { - errorMessage.value = 'Amber is only available on Android devices. Please use a Nostr browser extension instead.' - } else if (error.message?.includes('clipboard') || error.message?.includes('Empty')) { - errorMessage.value = 'Could not read from clipboard. Please ensure Amber is installed and clipboard permissions are granted.' + if (error.message?.includes('Clipboard is empty')) { + errorMessage.value = 'Clipboard is empty. Open Amber, approve the request, then come back and tap "Complete Sign-in".' + } 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 || 'Amber login failed. Please try again.' + errorMessage.value = error.message || 'Failed to read from Amber. Please try again.' } } } @@ -375,6 +461,17 @@ declare global { box-shadow: 0 0 20px rgba(247, 147, 26, 0.1); } +.amber-login-button--active { + background: rgba(247, 147, 26, 0.15); + border-color: rgba(247, 147, 26, 0.5); + box-shadow: 0 0 24px rgba(247, 147, 26, 0.15); +} + +.amber-login-button--active:hover { + background: rgba(247, 147, 26, 0.22); + box-shadow: 0 0 30px rgba(247, 147, 26, 0.2); +} + .amber-login-button:disabled { opacity: 0.5; cursor: not-allowed;