fix: Amber login on mobile with two-phase clipboard flow

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 <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-13 22:23:34 +00:00
parent 2fdb119ee5
commit 6bb95fd004
2 changed files with 118 additions and 21 deletions

View File

@@ -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

View File

@@ -103,13 +103,14 @@
Sign in with Nostr Extension
</button>
<!-- Amber Login Button (NIP-55 Android Signer) -->
<!-- Amber Login (NIP-55 Android Signer) -->
<!-- Phase 1: Open Amber to request public key -->
<button
@click="handleAmberLogin"
v-if="amberPhase === 'idle'"
@click="handleAmberOpen"
:disabled="isLoading"
class="amber-login-button w-full flex items-center justify-center gap-2 mt-3"
>
<!-- Amber shield icon -->
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.2" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M12 8v4m0 0v4m0-4h4m-4 0H8" stroke="#F7931A" stroke-width="2" stroke-linecap="round"/>
@@ -117,6 +118,30 @@
Sign in with Amber
</button>
<!-- Phase 2: User returned from Amber tap to read clipboard -->
<div v-else-if="amberPhase === 'waiting'" class="mt-3 space-y-3">
<p class="text-sm text-white/60 text-center">
Approved in Amber? Tap below to complete sign-in.
</p>
<button
@click="handleAmberComplete"
:disabled="isLoading"
class="amber-login-button amber-login-button--active w-full flex items-center justify-center gap-2"
>
<svg class="w-5 h-5 animate-pulse" viewBox="0 0 24 24" fill="none">
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.3" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M9 12l2 2 4-4" stroke="#F7931A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Complete Sign-in
</button>
<button
@click="amberPhase = 'idle'"
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
>
Cancel
</button>
</div>
<!-- Toggle Mode -->
<div class="mt-6 text-center text-sm text-white/60">
{{ mode === 'login' ? "Don't have an account?" : "Already have an account?" }}
@@ -156,7 +181,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
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<string | null>(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;