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:
Dorian
2026-02-14 09:28:25 +00:00
parent 38293b1f95
commit 276dab207c
6 changed files with 434 additions and 170 deletions

View File

@@ -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.'
}
}

View File

@@ -312,6 +312,8 @@ async function fetchStream() {
isLoadingStream.value = true
streamError.value = null
let streamFile: string | null = null
try {
// Try the backend stream endpoint (handles DRM, presigned URLs, etc.)
const info = await indeehubApiService.getStreamingUrl(contentId)
@@ -322,20 +324,15 @@ async function fetchStream() {
if (errorMsg) {
console.warn('[VideoPlayer] Backend returned error:', errorMsg)
streamError.value = errorMsg
return
} else {
const url = (info as any).file || info.url
if (!url) {
console.error('[VideoPlayer] No stream URL in response:', info)
streamError.value = 'No video URL returned from server.'
} else {
streamFile = url
}
}
const streamFile = (info as any).file || info.url
if (!streamFile) {
console.error('[VideoPlayer] No stream URL in response:', info)
streamError.value = 'No video URL returned from server.'
return
}
console.log('[VideoPlayer] Playing URL:', streamFile)
hlsStreamUrl.value = streamFile
await nextTick()
initPlayer(streamFile)
} catch (err: any) {
const status = err?.response?.status
const responseData = err?.response?.data
@@ -353,6 +350,15 @@ async function fetchStream() {
} finally {
isLoadingStream.value = false
}
// Init the player AFTER isLoadingStream is false so the <video> element
// is rendered in the DOM and videoEl ref is available.
if (streamFile && !streamError.value) {
console.log('[VideoPlayer] Playing URL:', streamFile)
hlsStreamUrl.value = streamFile
await nextTick()
initPlayer(streamFile)
}
}
function initPlayer(url: string) {