feat: implement three-phase Amber login flow in AuthModal
- Updated Amber login button to reflect new three-phase process. - Added handling for reading public key and signature from clipboard. - Introduced new state management for Amber login phases. - Enhanced user feedback during each phase with appropriate messaging. - Refactored related functions for clarity and maintainability.
This commit is contained in:
@@ -86,7 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amber Login (NIP-55 Android Signer) -->
|
||||
<!-- Amber Login (NIP-55 Android Signer) — 3-phase flow -->
|
||||
<button
|
||||
v-if="amberPhase === 'idle'"
|
||||
@click="handleAmberOpen"
|
||||
@@ -99,12 +99,38 @@
|
||||
</svg>
|
||||
Sign in with Amber
|
||||
</button>
|
||||
<div v-else-if="amberPhase === 'waiting'" class="mt-3 space-y-3">
|
||||
|
||||
<!-- Phase 2: Read pubkey from clipboard -->
|
||||
<div v-else-if="amberPhase === 'waiting-pubkey'" class="mt-3 space-y-3">
|
||||
<p class="text-sm text-white/60 text-center">
|
||||
Approved in Amber? Tap below to complete sign-in.
|
||||
Approved in Amber? Tap below to continue.
|
||||
</p>
|
||||
<button
|
||||
@click="handleAmberComplete"
|
||||
@click="handleAmberPubkeyRead"
|
||||
: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>
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
@click="amberPhase = 'idle'; amberPubkey = null"
|
||||
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Phase 3: Read signed NIP-98 event from clipboard -->
|
||||
<div v-else-if="amberPhase === 'waiting-signature'" class="mt-3 space-y-3">
|
||||
<p class="text-sm text-white/60 text-center">
|
||||
Approve the sign-in request in Amber, then tap below.
|
||||
</p>
|
||||
<button
|
||||
@click="handleAmberSignComplete"
|
||||
:disabled="isLoading"
|
||||
class="amber-login-button amber-login-button--active w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
@@ -115,7 +141,7 @@
|
||||
Complete Sign-in
|
||||
</button>
|
||||
<button
|
||||
@click="amberPhase = 'idle'"
|
||||
@click="amberPhase = 'idle'; amberPubkey = null"
|
||||
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
@@ -203,6 +229,7 @@ import { ref, computed, watch } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { useAccounts } from '../composables/useAccounts'
|
||||
import { accountManager } from '../lib/accounts'
|
||||
import { authService } from '../services/auth.service'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
@@ -227,8 +254,9 @@ const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const isLoading = computed(() => authLoading.value || sovereignGenerating.value)
|
||||
|
||||
// Amber two-phase login state
|
||||
const amberPhase = ref<'idle' | 'waiting'>('idle')
|
||||
// Amber three-phase login state
|
||||
const amberPhase = ref<'idle' | 'waiting-pubkey' | 'waiting-signature'>('idle')
|
||||
const amberPubkey = ref<string | null>(null)
|
||||
|
||||
// ── Sovereign Identity Flow ──────────────────────────────────────
|
||||
type SovereignPhase = 'normal' | 'nah' | 'own-privacy' | 'generated'
|
||||
@@ -254,6 +282,7 @@ function closeModal() {
|
||||
emit('close')
|
||||
errorMessage.value = null
|
||||
amberPhase.value = 'idle'
|
||||
amberPubkey.value = null
|
||||
sovereignPhase.value = 'normal'
|
||||
sovereignDismissed.value = false
|
||||
generatedKeys.value = null
|
||||
@@ -396,7 +425,7 @@ async function handleNostrLogin() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Amber Login — Phase 1: Open Amber via Android intent.
|
||||
* Amber Login — Phase 1: Open Amber via Android intent to get the pubkey.
|
||||
*
|
||||
* Amber copies the pubkey to the clipboard. Mobile browsers block
|
||||
* automatic clipboard reads (navigator.clipboard.readText requires a
|
||||
@@ -413,25 +442,41 @@ function handleAmberOpen() {
|
||||
return
|
||||
}
|
||||
|
||||
// Open Amber via the nostrsigner intent
|
||||
// Open Amber via the nostrsigner 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 "Complete Sign-in" after Amber approves
|
||||
amberPhase.value = 'waiting'
|
||||
// Switch to phase 2 — user will tap "Continue" after Amber approves
|
||||
amberPhase.value = 'waiting-pubkey'
|
||||
}
|
||||
|
||||
/**
|
||||
* Amber Login — Phase 2: Read the pubkey from the clipboard.
|
||||
* Read a pubkey from clipboard text. Handles hex, npub, and nprofile formats.
|
||||
*/
|
||||
async function decodePubkeyFromClipboard(clipboardText: string): Promise<string> {
|
||||
if (/^[0-9a-f]{64}$/i.test(clipboardText)) {
|
||||
return clipboardText
|
||||
}
|
||||
if (clipboardText.startsWith('npub') || clipboardText.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')
|
||||
return decoded.pubkey
|
||||
}
|
||||
throw new Error('Clipboard does not contain a valid Nostr public key. Got: ' + clipboardText.slice(0, 20) + '...')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function handleAmberComplete() {
|
||||
async function handleAmberPubkeyRead() {
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
// Read pubkey from clipboard (allowed because this is a user gesture)
|
||||
if (!navigator.clipboard?.readText) {
|
||||
throw new Error('Clipboard API not available')
|
||||
}
|
||||
@@ -441,55 +486,131 @@ async function handleAmberComplete() {
|
||||
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) + '...')
|
||||
}
|
||||
const pubkey = await decodePubkeyFromClipboard(clipboardText)
|
||||
amberPubkey.value = pubkey
|
||||
|
||||
// Register the Amber account in the account manager
|
||||
const { AmberClipboardSigner, AmberClipboardAccount, accountManager } = await import('../lib/accounts')
|
||||
// Register the Amber account in the account manager so the app
|
||||
// knows who is signing in (the signer is set but we won't call
|
||||
// signEvent on it — we use intents + clipboard for that)
|
||||
const { AmberClipboardSigner, AmberClipboardAccount } = 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', {
|
||||
// Build the unsigned NIP-98 event that the backend expects
|
||||
const sessionUrl = authService.getNostrSessionUrl()
|
||||
const unsignedEvent = {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', window.location.origin],
|
||||
['u', sessionUrl],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
pubkey,
|
||||
})
|
||||
}
|
||||
|
||||
// Open Amber to sign this NIP-98 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`
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function handleAmberSignComplete() {
|
||||
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. Make sure you approved the signing request in Amber.')
|
||||
}
|
||||
|
||||
// Amber may return the full signed event JSON or just the signature hex
|
||||
let authHeader: string
|
||||
|
||||
if (clipboardText.startsWith('{')) {
|
||||
// Full signed event JSON — use directly
|
||||
const signedEvent = JSON.parse(clipboardText)
|
||||
if (!signedEvent.sig) {
|
||||
throw new Error('Signed event is missing the signature. Please try again.')
|
||||
}
|
||||
authHeader = `Nostr ${btoa(JSON.stringify(signedEvent))}`
|
||||
} else if (/^[0-9a-f]{128}$/i.test(clipboardText)) {
|
||||
// Just the signature hex — we need to reconstruct the full event
|
||||
// Build the same unsigned event we sent to Amber
|
||||
const pubkey = amberPubkey.value
|
||||
if (!pubkey) throw new Error('Lost track of the pubkey. Please start over.')
|
||||
|
||||
const sessionUrl = authService.getNostrSessionUrl()
|
||||
const event = {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', sessionUrl],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
pubkey,
|
||||
sig: clipboardText,
|
||||
}
|
||||
|
||||
// Calculate event ID using nostr-tools
|
||||
const { getEventHash } = await import('nostr-tools/pure')
|
||||
;(event as any).id = getEventHash(event as any)
|
||||
|
||||
authHeader = `Nostr ${btoa(JSON.stringify(event))}`
|
||||
} else {
|
||||
throw new Error('Clipboard does not contain a signed event. Got: ' + clipboardText.slice(0, 20) + '...')
|
||||
}
|
||||
|
||||
const pubkey = amberPubkey.value
|
||||
if (!pubkey) throw new Error('Lost track of the pubkey. Please start over.')
|
||||
|
||||
// Authenticate with the pre-signed NIP-98 header (bypasses signer.signEvent)
|
||||
await loginWithNostr(pubkey, 'amber-nip55', {}, authHeader)
|
||||
|
||||
amberPhase.value = 'idle'
|
||||
amberPubkey.value = null
|
||||
emit('success')
|
||||
closeModal()
|
||||
} catch (error: any) {
|
||||
console.error('Amber clipboard read failed:', error)
|
||||
console.error('Amber sign-in failed:', error)
|
||||
handleAmberError(error)
|
||||
}
|
||||
}
|
||||
|
||||
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 || 'Failed to read from Amber. Please try again.'
|
||||
}
|
||||
/**
|
||||
* Shared error handler for Amber flow phases.
|
||||
*/
|
||||
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.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user