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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user