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.
This commit is contained in:
Dorian
2026-02-14 13:07:52 +00:00
parent 11d289d793
commit e10b0a0406

View File

@@ -64,31 +64,29 @@
</div>
</div>
<!-- Nostr Login Button (NIP-07 Browser Extension) only shown when extension is installed -->
<template v-if="hasNostrExtension">
<button
@click="handleNostrLogin"
:disabled="isLoading"
class="nostr-login-button w-full flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Sign in with Nostr Extension
</button>
<!-- Nostr Login Button (NIP-07 Browser Extension) -->
<button
@click="handleNostrLogin"
:disabled="isLoading"
class="nostr-login-button w-full flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Sign in with Nostr Extension
</button>
<!-- Divider between Nostr methods -->
<div class="relative my-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-white/10"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-transparent text-white/30 text-xs">or</span>
</div>
<!-- Divider between Nostr methods -->
<div class="relative my-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-white/10"></div>
</div>
</template>
<div class="relative flex justify-center text-sm">
<span class="px-4 bg-transparent text-white/30 text-xs">or</span>
</div>
</div>
<!-- Amber Login (NIP-55 Android Signer) 3-phase flow -->
<!-- Amber Login (NIP-55 Android Signer) -->
<button
v-if="amberPhase === 'idle'"
@click="handleAmberOpen"
@@ -102,54 +100,97 @@
Sign in with Amber
</button>
<!-- Phase 2: Read pubkey from clipboard -->
<!-- Waiting for pubkey from Amber (auto-detects on return) -->
<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 continue.
</p>
<button
@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"/>
<div class="flex items-center justify-center gap-2 text-sm text-[#F7931A]">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Continue
</button>
<span>Waiting for Amber...</span>
</div>
<p class="text-xs text-white/40 text-center">Approve the request in Amber, then come back here</p>
<!-- Paste fallback (shown after timeout) -->
<div v-if="showPasteFallback" class="space-y-2 pt-2 border-t border-white/5">
<p class="text-xs text-white/50 text-center">Clipboard didn't work? Paste your public key:</p>
<div class="flex gap-2">
<input
v-model="amberPasteInput"
type="text"
placeholder="npub1... or hex pubkey"
class="auth-input flex-1 text-xs"
/>
<button
@click="handleAmberPasteSubmit"
:disabled="!amberPasteInput.trim()"
class="amber-login-button px-4 text-xs"
:class="{ 'opacity-40 cursor-not-allowed': !amberPasteInput.trim() }"
>
Go
</button>
</div>
</div>
<button
@click="amberPhase = 'idle'; amberPubkey = null; amberUnsignedEvent = null"
@click="cancelAmber"
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 -->
<!-- Waiting for NIP-98 signature from Amber (auto-detects on return) -->
<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"
>
<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"/>
<div class="flex items-center justify-center gap-2 text-sm text-[#F7931A]">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Complete Sign-in
</button>
<span>Approve sign-in in Amber...</span>
</div>
<p class="text-xs text-white/40 text-center">Approve the signing request, then come back here</p>
<!-- Paste fallback (shown after timeout) -->
<div v-if="showPasteFallback" class="space-y-2 pt-2 border-t border-white/5">
<p class="text-xs text-white/50 text-center">Clipboard didn't work? Paste the signature:</p>
<div class="flex gap-2">
<input
v-model="amberPasteInput"
type="text"
placeholder="Hex signature or signed event JSON"
class="auth-input flex-1 text-xs"
/>
<button
@click="handleAmberSignPasteSubmit"
:disabled="!amberPasteInput.trim()"
class="amber-login-button px-4 text-xs"
:class="{ 'opacity-40 cursor-not-allowed': !amberPasteInput.trim() }"
>
Go
</button>
</div>
</div>
<button
@click="amberPhase = 'idle'; amberPubkey = null; amberUnsignedEvent = null"
@click="cancelAmber"
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
>
Cancel
</button>
</div>
<!-- Completing sign-in -->
<div v-else-if="amberPhase === 'completing'" class="mt-3">
<div class="flex items-center justify-center gap-2 text-sm text-[#F7931A]">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>Completing sign-in...</span>
</div>
</div>
<!-- Toggle Mode -->
<div class="mt-6 text-center text-sm text-white/60">
{{ 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<string | null>(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<string | null>(null)
/** The exact unsigned NIP-98 event sent to Amber for signing (reused in Phase 3) */
const amberUnsignedEvent = ref<any>(null)
const amberPasteInput = ref('')
const showPasteFallback = ref(false)
let amberFallbackTimer: ReturnType<typeof setTimeout> | 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<string> {
if (/^[0-9a-f]{64}$/i.test(clipboardText)) {
return clipboardText
}
if (clipboardText.startsWith('npub') || clipboardText.startsWith('nprofile')) {
async function decodePubkeyText(text: string): Promise<string> {
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